diff --git a/.clue/id-ledger.yaml b/.clue/id-ledger.yaml index 6c06df7..8e85976 100644 --- a/.clue/id-ledger.yaml +++ b/.clue/id-ledger.yaml @@ -22,3 +22,21 @@ events: - {id: RDA-003, kind: numeric, state: live, prefix: RDA, component: "3"} - {id: RDA-004, kind: numeric, state: live, prefix: RDA, component: "4"} - {id: RDA-005, kind: numeric, state: live, prefix: RDA, component: "5"} + - {id: CH-001, kind: numeric, state: reserved, prefix: CH, component: "1"} + - {id: ADR-003, kind: numeric, state: reserved, prefix: ADR, component: "3"} + - {id: TASKS-001, kind: numeric, state: reserved, prefix: TASKS, component: "1"} + - {id: OQ-001, kind: numeric, state: reserved, prefix: OQ, component: "1"} + - {id: CH-001, kind: numeric, state: live, prefix: CH, component: "1"} + - {id: ADR-003, kind: numeric, state: live, prefix: ADR, component: "3"} + - {id: TASKS-001, kind: numeric, state: live, prefix: TASKS, component: "1"} + - {id: OQ-001, kind: numeric, state: live, prefix: OQ, component: "1"} + - {id: RDA-006, kind: numeric, state: reserved, prefix: RDA, component: "6"} + - {id: RDA-007, kind: numeric, state: reserved, prefix: RDA, component: "7"} + - {id: RDA-008, kind: numeric, state: reserved, prefix: RDA, component: "8"} + - {id: RDA-009, kind: numeric, state: reserved, prefix: RDA, component: "9"} + - {id: RBC-005, kind: numeric, state: reserved, prefix: RBC, component: "5"} + - {id: RDA-006, kind: numeric, state: live, prefix: RDA, component: "6"} + - {id: RDA-007, kind: numeric, state: live, prefix: RDA, component: "7"} + - {id: RDA-008, kind: numeric, state: live, prefix: RDA, component: "8"} + - {id: RDA-009, kind: numeric, state: live, prefix: RDA, component: "9"} + - {id: RBC-005, kind: numeric, state: live, prefix: RBC, component: "5"} diff --git a/.github/workflows/ingest.yml b/.github/workflows/ingest.yml index 0318cb1..8a08c5b 100644 --- a/.github/workflows/ingest.yml +++ b/.github/workflows/ingest.yml @@ -8,11 +8,12 @@ on: workflow_dispatch: permissions: + actions: write contents: write issues: write concurrency: - group: rumble-result-ingestion + group: rumble-publication-writer cancel-in-progress: false jobs: @@ -21,9 +22,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: main - uses: actions/setup-python@v5 with: python-version: '3.12' + - name: Snapshot completed UTC months before accepting results + run: python scripts/publication.py --root . --rollover-only - name: Stage labelled issue inbox env: GH_TOKEN: ${{ github.token }} @@ -49,15 +54,22 @@ jobs: printf '%s\n' "$number" >> .inbox/processed-issues done < .inbox/issues - name: Regenerate projections and commit accepted facts + env: + GH_TOKEN: ${{ github.token }} run: | - python scripts/aggregate.py --root . + python scripts/publication.py --root . git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + site_changed=false + [ -n "$(git status --porcelain -- site)" ] && site_changed=true for path in results leaderboard matchmaking clients.json site/data; do [ -e "$path" ] && git add -- "$path" done - git diff --cached --quiet || git commit -m 'chore: ingest Rumble result batch' - git push + if ! git diff --cached --quiet; then + git commit -m 'chore: ingest Rumble result batch' + git push + [ "$site_changed" = false ] || gh workflow run pages.yml --ref main + fi - name: Publish durable receipts env: GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 7400c64..eab50a0 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -4,9 +4,12 @@ on: push: branches: [main] paths: [site/**] + schedule: + - cron: '41 * * * *' workflow_dispatch: permissions: + actions: read contents: read pages: write id-token: write @@ -23,9 +26,31 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/configure-pages@v5 - - uses: actions/upload-pages-artifact@v3 + with: + fetch-depth: 0 + - id: reconcile + name: Compare the site with the latest successful deployment + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + if [ "$GITHUB_EVENT_NAME" != schedule ]; then + echo 'deploy=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + deployed=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/pages.yml/runs?status=success&per_page=1" --jq '.workflow_runs[0].head_sha // empty') + if [ -z "$deployed" ] || ! git cat-file -e "$deployed^{commit}" || ! git diff --quiet "$deployed" HEAD -- site; then + echo 'deploy=true' >> "$GITHUB_OUTPUT" + else + echo 'deploy=false' >> "$GITHUB_OUTPUT" + echo 'The deployed site already matches the current site tree.' + fi + - if: steps.reconcile.outputs.deploy == 'true' + uses: actions/configure-pages@v5 + - if: steps.reconcile.outputs.deploy == 'true' + uses: actions/upload-pages-artifact@v3 with: path: site - id: deployment + if: steps.reconcile.outputs.deploy == 'true' uses: actions/deploy-pages@v4 diff --git a/.github/workflows/sync-catalog.yml b/.github/workflows/sync-catalog.yml index 40a7613..5eac102 100644 --- a/.github/workflows/sync-catalog.yml +++ b/.github/workflows/sync-catalog.yml @@ -6,22 +6,48 @@ on: workflow_dispatch: permissions: + actions: write contents: write +concurrency: + group: rumble-publication-writer + cancel-in-progress: false + jobs: synchronize: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: main - uses: actions/setup-python@v5 with: python-version: '3.12' - - run: python scripts/sync_catalog.py --root . - - run: python scripts/aggregate.py --root . + - name: Snapshot completed UTC months before synchronizing the catalog + run: python scripts/publication.py --root . --rollover-only + - id: catalog + name: Synchronize catalog and regenerate only when it changed + shell: bash + run: | + python scripts/sync_catalog.py --root . + if git diff --quiet -- catalog.json; then + echo 'changed=false' >> "$GITHUB_OUTPUT" + echo 'The source catalog is unchanged; skipping aggregation.' + else + echo 'changed=true' >> "$GITHUB_OUTPUT" + python scripts/publication.py --root . + fi - name: Commit synchronized catalog and projections + env: + GH_TOKEN: ${{ github.token }} run: | git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + site_changed=false + [ -n "$(git status --porcelain -- site)" ] && site_changed=true git add catalog.json leaderboard matchmaking clients.json site/data - git diff --cached --quiet || git commit -m 'chore: synchronize Rumble bot catalog and projections' - git push + if ! git diff --cached --quiet; then + git commit -m 'chore: synchronize Rumble bot catalog and projections' + git push + [ "$site_changed" = false ] || gh workflow run pages.yml --ref main + fi diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index c90c822..de14f0d 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -13,9 +13,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: '3.12' - run: python -m unittest discover -s tests -v + - if: github.event_name == 'pull_request' + run: python scripts/check_snapshots.py --base "origin/${{ github.base_ref }}" - run: python scripts/aggregate.py --root . - run: git diff --exit-code -- leaderboard matchmaking clients.json site/data diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e9c12af --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## Unreleased + +- Make catalog-driven ranking publication change-aware, explicitly deploy changed generated site data, expose ranking freshness, and add immutable cumulative month-end snapshots selectable from the dashboard. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 2476cfa..c6d8cf9 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -18,9 +18,11 @@ Aggregation reapplies current registrations, bans, bot disqualifications, and ex ## Ingestion and dashboard operations -A newly labelled result issue normally triggers ingestion immediately. The scheduled fallback runs at 17 and 47 minutes past every UTC hour. Runs are serialized, and each drain processes the complete labelled inbox, commits accepted facts and projections together, publishes receipts, and closes processed issues. +A newly labelled result issue normally triggers ingestion immediately. The scheduled fallback runs at 17 and 47 minutes past every UTC hour. Result and catalog writers share one non-cancelling concurrency group. Each drain first snapshots any completed UTC month, processes the complete labelled inbox, commits changed accepted facts and projections together, explicitly requests Pages when site data changed, then publishes receipts and closes processed issues. -Catalog synchronization runs at 23 minutes past every UTC hour. Dashboard deployment runs after a push changes `site/`. +Catalog polling runs at 23 minutes past every UTC hour. If normalized source content is identical, it skips aggregation, commit, and deployment. A changed catalog is regenerated and committed, and its writer explicitly requests the Pages workflow because a push made with the built-in Actions token does not start another push-triggered workflow. Pages also reconciles the current `site/` tree against the latest successful deployment at 41 minutes past each UTC hour and deploys only when they differ. + +At the first writer run after a UTC month boundary, `scripts/publication.py` copies current leaderboard and bot-detail JSON into `site/data/snapshots/YYYY-MM/` before accepting or synchronizing new input. Existing snapshots are immutable and pull-request verification rejects modifications or deletions. If writers were inactive across multiple boundaries, each missing month receives the same last published cumulative state. Snapshot creation does not reset current rankings or advance `lastUpdatedAt`. If GitHub disables scheduled workflows after inactivity, re-enable them. An incoming labelled issue can wake ingestion, but it does not replace routine operational checks. diff --git a/README.md b/README.md index 59a39aa..ddf19cb 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,13 @@ Most battle contributors should use the Rumble Client rather than create result ## When the dashboard updates -A result issue normally starts ingestion as soon as GitHub applies the `result-submission` label. A scheduled fallback runs at 17 and 47 minutes past every UTC hour. Each successful drain commits accepted facts, regenerates the projections, and triggers a Pages deployment when dashboard data changed. +A result issue normally starts ingestion as soon as GitHub applies the `result-submission` label. A scheduled fallback runs at 17 and 47 minutes past every UTC hour. Each drain regenerates the projections, but it commits and requests a Pages deployment only when accepted facts, current ranking data, or month history changed. -The reviewed bot catalog synchronizes at 23 minutes past every UTC hour. A newly merged bot appears after that synchronization and starts with no ranked samples. +The reviewed bot catalog is checked at 23 minutes past every UTC hour. Identical source content stops without aggregation, a commit, or a deployment. A newly merged bot or bot version changes the catalog, triggers ranking regeneration and publication, and starts with no ranked samples. -GitHub Actions schedules may run late, so these times describe the automation cadence rather than a delivery guarantee. +The Pages workflow also checks at 41 minutes past each UTC hour whether the current `site/` tree differs from the latest successful deployment. This reconciliation recovers a missed deployment without republishing an unchanged site. GitHub Actions schedules may run late, so these times describe the automation cadence rather than a delivery guarantee. + +The dashboard's “ranking data last updated” value advances only when current leaderboard or bot-detail JSON changes. A workflow run, unchanged regeneration, deployment, or monthly snapshot by itself does not advance it. ## How results become rankings @@ -22,7 +24,11 @@ Submitted issue bodies are transport, not durable storage. The ingestion workflo `scripts/aggregate.py` derives the leaderboard, pairing statistics, matchmaking advice, client totals, and dashboard data from repository-tracked inputs. The generated projections are disposable; accepted facts are the source of truth. -The current dashboard ranks each game type by APS, or Average Percentage Score. It also shows how many battles and distinct matchups contribute to each entry. +The current dashboard ranks each game type by APS, or Average Percentage Score. For each accepted battle, a participant's score share is its `totalScore` divided by the sum of all participants' `totalScore` values; a zero total produces a zero share. Battles are grouped by the exact sorted set of participating bot name-and-version identities. Repeated battles are averaged within each distinct pairing, then APS is 100 times the mean of those pairing averages, so every distinct pairing has equal weight regardless of how many samples it has. Stored APS is rounded to four decimal places and the dashboard displays two. + +Only accepted facts matching the current `engine.json` `behaviorVersion` and matchups whose complete participant set consists of currently active, game-type-eligible identities contribute. The live leaderboard contains only catalog entries whose exact name and version are currently `active`. When a new version becomes active, it is a separate identity that starts at APS 0 with no samples; its superseded version disappears, and matchups containing that old version stop affecting every participant's live APS. Entries sort by descending APS with a stable bot-identity tie break. + +The live ranking remains cumulative rather than resetting each month. Before the first result or catalog writer proceeds in a new UTC month, it saves the previous current leaderboard and bot details as an immutable month-end snapshot. The dashboard period selector exposes these read-only snapshots; late results affect the current ranking only and never rewrite past months. The first snapshot is created at the first month boundary after this feature is deployed, with no synthetic backfill. ## Repository map @@ -35,6 +41,8 @@ The current dashboard ranks each game type by APS, or Average Percentage Score. | `catalog.json` | Synchronized copy of the reviewed Rumble bot catalog. | | `engine.json` | Pinned game behavior and ranked presets. | | `site/` | Static dashboard published through GitHub Pages. | +| `site/data/history.json` | Current publication freshness and available monthly snapshots. | +| `site/data/snapshots/YYYY-MM/` | Immutable cumulative month-end leaderboard and bot-detail JSON. | | `wellknown/rumble.json` | Canonical repository pointer used by clients. | `engine.json.clientImage` is optional while no production Rumble Client image is published. When added, it must use an immutable image digest. Ranked compatibility is determined by `behaviorVersion`, not by the presence of an image. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index cb0b121..2afa3d1 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -23,20 +23,26 @@ flowchart LR extract --> validate["validate.py"] validate --> ingest["ingest.py"] ingest -->|"content-addressed\nimmutable write"| raw[("results/raw/**\n(git-tracked)")] - ingestWF --> aggregate["aggregate.py"] + ingestWF --> publish["publication.py\nrollover + changed-data timestamp"] + publish --> aggregate["aggregate.py"] raw --> aggregate rollups[("results/rollups/**\narchive branch")] --> aggregate catalogFile[("catalog.json")] --> aggregate aggregate --> lb[("leaderboard/**")] aggregate --> mm[("matchmaking/**")] aggregate --> siteData[("site/data/**")] + publish --> snapshots[("site/data/snapshots/**\nimmutable month ends")] + publish --> history[("site/data/history.json")] aggregate --> clientsJson[("clients.json")] catalogSrc["external bot catalog\nsource (HTTPS)"] -->|"23 * cron"| syncWF["sync-catalog.yml"] syncWF --> syncCat["sync_catalog.py"] syncCat --> catalogFile + syncWF --> publish - siteData --> pagesWF["pages.yml\n(on push to site/**)"] + siteData --> pagesWF["pages.yml\n(explicit dispatch + reconciliation)"] + snapshots --> pagesWF + history --> pagesWF pagesWF --> pages[("GitHub Pages")] pages --> viewer["Dashboard viewer\n(external)"] @@ -50,10 +56,11 @@ flowchart LR - **Transport** (`scripts/extract_envelope.py`) — pulls the one required fenced JSON block out of an issue body; owns no state. - **Validation** (`scripts/validate.py`) — pure functions checking one record against `engine.json`, `catalog.json`, `clients/*.json`, and `bans.json`; owns no state. - **Ingestion** (`scripts/ingest.py`) — the only writer of `results/raw/`; enforces idempotency and immutability. -- **Aggregation** (`scripts/aggregate.py`) — the only writer of `leaderboard/`, `matchmaking/`, `site/data/`, and `clients.json`; fully regenerates them from tracked inputs every run. +- **Aggregation** (`scripts/aggregate.py`) — the only writer of current `leaderboard/`, `matchmaking/`, current leaderboard/bot data below `site/data/`, and `clients.json`; fully regenerates them from tracked inputs every run and owns no clock. +- **Publication** (`scripts/publication.py`) — serializes result and catalog writers, snapshots completed UTC months before new input, and advances public freshness only when current ranking bytes change. - **Compaction** (`scripts/compact.py`) — moves aged facts into monthly rollups on a separate `archive` branch checkout, verifying aggregation is unchanged before committing the move. - **Catalog sync** (`scripts/sync_catalog.py`) — the only writer of `catalog.json`. -- **Dashboard** (`site/`) — static HTML/CSS/JS with no build step and no server; reads only generated JSON under `site/data/`. +- **Dashboard** (`site/`) — static HTML/CSS/JS with no build step and no server; reads current generated JSON and immutable monthly snapshots through `site/data/history.json`. - **Shared kernel** (`scripts/common.py`) — canonical JSON, content addressing, and the catalog team-membership contract every other component depends on. See `CAP-001-ranked-result-pipeline` and `CAP-002-bot-catalog-sync` for behavior inside these boundaries, and `../design/README.md` for the runtime flows connecting them. @@ -63,13 +70,13 @@ See `CAP-001-ranked-result-pipeline` and `CAP-002-bot-catalog-sync` for behavior - **Python standard library only** — no third-party dependency for any script (`README.md`); keeps the fork drill (`G-003`) from depending on package availability. - **Git as the database** — accepted facts and generated projections are ordinary tracked files; there is no external datastore, and history is the audit trail. - **Content-addressed, immutable facts** — every accepted record's filename is a SHA-256 hash of its own normalized content (`common.content_hash`), which is what makes retries idempotent and edits detectable. -- **Regenerate, never patch, derived data** — `leaderboard/`, `matchmaking/`, `clients.json`, and `site/data/` are always fully rebuilt from `results/`, `catalog.json`, `clients/`, `bans.json`, and `exclusions.json`; `.github/workflows/verify.yml` fails the build if committed derived data ever drifts from what regeneration produces. +- **Regenerate current projections, append historical publications** — `leaderboard/`, `matchmaking/`, `clients.json`, and current ranking JSON are fully rebuilt from `results/`, `catalog.json`, `clients/`, `bans.json`, and `exclusions.json`; monthly snapshot paths are append-only records and pull-request verification rejects changes to an existing snapshot. - **GitHub Issues as the submission transport, GitHub Actions as the only runtime, GitHub Pages as the only hosting** — no bespoke server or API; see `ADR-001` and `ADR-002`. - **A parallel `archive` git branch for compacted history** — keeps `main`'s working tree from growing unbounded while keeping compacted facts in git rather than deleting them. ## Related decisions -`ADR-001` (immutable, content-addressed facts with disposable projections) and `ADR-002` (GitHub Issues as the submission transport) are the architectural decisions behind this shape; see `../decisions/README.md`. +`ADR-003` (immutable facts, cumulative current projections, and immutable monthly publication snapshots) and `ADR-002` (GitHub Issues as the submission transport) are the architectural decisions behind this shape; see `../decisions/README.md`. diff --git a/docs/capabilities/CAP-001-ranked-result-pipeline/README.md b/docs/capabilities/CAP-001-ranked-result-pipeline/README.md index 51cd6e6..9ab4511 100644 --- a/docs/capabilities/CAP-001-ranked-result-pipeline/README.md +++ b/docs/capabilities/CAP-001-ranked-result-pipeline/README.md @@ -11,7 +11,7 @@ title: Ranked result pipeline # CAP-001 — Ranked result pipeline -What the system can do: accept a battle-result batch from a registered client, validate every record independently against the pinned engine and reviewed bot catalog, persist each accepted record once as an immutable, content-addressed fact, and derive the leaderboard, matchmaking advice, and dashboard data from the accepted facts plus current moderation state. +What the system can do: accept a battle-result batch from a registered client, validate every record independently against the pinned engine and reviewed bot catalog, persist each accepted record once as an immutable, content-addressed fact, derive a cumulative active-version leaderboard from accepted facts plus current moderation state, and publish current rankings with immutable cumulative month-end snapshots. This is the capability `G-001` (a trustworthy, auditable leaderboard) and `G-002` (low-toil automated operation) both depend on: it is the whole path from `scripts/extract_envelope.py` and `scripts/validate.py` through `scripts/ingest.py`, `scripts/aggregate.py`, and `scripts/compact.py` to `leaderboard/`, `matchmaking/`, `clients.json`, and `site/data/`. diff --git a/docs/capabilities/CAP-001-ranked-result-pipeline/criteria.md b/docs/capabilities/CAP-001-ranked-result-pipeline/criteria.md index 12596e0..2bffe6d 100644 --- a/docs/capabilities/CAP-001-ranked-result-pipeline/criteria.md +++ b/docs/capabilities/CAP-001-ranked-result-pipeline/criteria.md @@ -13,8 +13,10 @@ Each test method in `tests/test_rumble_data.py` embeds its criterion ID, declare `clue validate` auto-classifies test evidence only in Go, JVM, or Cucumber, so it cannot verify these Python references directly; each scenario cites its test methods below as a direct reference instead. +Every criterion remains tagged `@draft` only because the repository's executable evidence is Python, which the current Cliewen evidence classifier cannot recognize. Its focused tests run in `verify.yml`; the tag records the unsupported formal proof carrier rather than an unimplemented behavior. + ```gherkin -@RDA-001 +@RDA-001 @draft Scenario: A valid submission from a registered client becomes an immutable ranked fact Test-type: Integration Given a battle result from a registered client and client ID, for a supported game type, matching the pinned engine and an active catalog entry @@ -35,7 +37,7 @@ Evidence (all positive-direction facets of this one criterion): - `testRDA001_IntegrationPositive_catalog_sync_admits_published_bot_results` — a bot newly admitted by `CAP-002` can immediately submit results ```gherkin -@RDA-002 +@RDA-002 @draft Scenario: A structurally invalid record is rejected and never persisted Test-type: Integration Given a batch containing a record that fails schema, identity, engine-pin, or score-consistency validation @@ -52,7 +54,7 @@ Evidence (negative direction): - `testRDA002_IntegrationNegative_rejects_each_documented_structural_violation` — table-driven coverage of client identity, engine pin, arena dimensions, isTeam, score typing, 1224 rank system, and place-count bounds ```gherkin -@RDA-003 +@RDA-003 @draft Scenario: Projections stay deterministic and reflect current moderation, independent of when facts were recorded Test-type: Integration Given accepted facts recorded under registrations, bans, and exclusions that have since changed, and facts that have since been compacted into monthly rollups @@ -67,7 +69,7 @@ Evidence: - `testRDA003_IntegrationPositive_current_bans_and_registration_filter_existing_facts` ```gherkin -@RDA-004 +@RDA-004 @draft Scenario: The dashboard reads generated projections rather than embedding data of its own Test-type: E2E Given the published static site @@ -81,7 +83,7 @@ Evidence: - `testRDA004_E2EPositive_dashboard_references_versioned_projection_and_bot_details` ```gherkin -@RDA-005 +@RDA-005 @draft Scenario: Catalog membership and eligibility gate both validation and matchmaking Test-type: Integration Given the current active bot catalog, including which entries are individual bots and which are teams @@ -97,3 +99,74 @@ Evidence (positive): Evidence (negative): - `testRDA005_IntegrationNegative_rejects_ineligible_or_overlapping_team_entries` — an individual entered as a team, a team entered as an individual, and two teams sharing a member are each rejected; an unknown or inactive team member fails engine-pin validation outright + +```gherkin +@RDA-006 @draft +Scenario: APS gives each distinct pairing equal weight within the active ranking epoch +Test-type: Integration + Given eligible facts for the current behavior version and the catalog's active bot versions + When a game type's leaderboard is generated + Then each battle contributes the participant's share of that battle's total score, repeated battles are averaged within their exact participant pairing, and APS is 100 times the mean of those pairing averages + And each distinct pairing has equal weight regardless of its battle count + And superseded versions and facts from another behavior version do not contribute, while an active version with no samples has APS zero +``` + +Evidence: + +- `testRDA006_IntegrationPositive_aps_weights_each_distinct_pairing_equally` +- `testRDA006_IntegrationPositive_equal_aps_uses_total_identity_order` +- `testRDA006_IntegrationNegative_live_ranking_excludes_superseded_and_wrong_epoch_results` + +```gherkin +@RDA-007 @draft +Scenario: Publication freshness changes only when current ranking data changes +Test-type: Integration + Given a current leaderboard and its publication timestamp + When publication regenerates the current leaderboard + Then a visible ranking change advances lastUpdatedAt to the publication time + And identical ranking output preserves the previous lastUpdatedAt + And creating a history snapshot alone does not advance lastUpdatedAt +``` + +Evidence: + +- `testRDA007_IntegrationPositive_visible_ranking_change_advances_publication_time` +- `testRDA007_IntegrationPositive_changed_writers_explicitly_dispatch_pages` +- `testRDA007_IntegrationPositive_detects_ranking_regenerated_outside_publication` +- `testRDA007_IntegrationNegative_unchanged_ranking_preserves_publication_time` +- `testRDA007_IntegrationNegative_snapshot_alone_preserves_publication_time` + +```gherkin +@RDA-008 @draft +Scenario: The first writer after a UTC month boundary preserves immutable cumulative history +Test-type: Integration + Given the current cumulative leaderboard and the month in which it was last rolled over + When a serialized writer first runs in a later UTC month + Then it copies the current leaderboard and bot detail JSON byte for byte into a snapshot for every completed month before accepting new input + And its first run initializes the current month without inventing earlier snapshots + And it adds each snapshot to the history manifest without resetting the live cumulative ranking + And it refuses to alter, delete, or overwrite an existing snapshot +``` + +Evidence: + +- `testRDA008_IntegrationPositive_rollover_copies_each_missing_month_byte_for_byte` +- `testRDA008_IntegrationPositive_first_rollover_initializes_without_backfill` +- `testRDA008_IntegrationPositive_rollover_recovers_an_identical_partial_copy` +- `testRDA008_IntegrationNegative_rollover_refuses_to_overwrite_a_snapshot` + +```gherkin +@RDA-009 @draft +Scenario: A dashboard viewer can distinguish current rankings from read-only monthly history +Test-type: E2E + Given the publication history manifest and current and archived leaderboard data + When a viewer selects Current or a completed month and a game type + Then the dashboard loads the corresponding leaderboard and bot details + And it shows when that ranking data last changed + And an archived month is clearly identified as a read-only month-end snapshot +``` + +Evidence: + +- `testRDA009_E2EPositive_dashboard_selects_current_or_archived_data` +- `testRDA009_E2ENegative_dashboard_marks_archived_rankings_read_only` diff --git a/docs/capabilities/CAP-001-ranked-result-pipeline/design.md b/docs/capabilities/CAP-001-ranked-result-pipeline/design.md index ac79047..2d1b450 100644 --- a/docs/capabilities/CAP-001-ranked-result-pipeline/design.md +++ b/docs/capabilities/CAP-001-ranked-result-pipeline/design.md @@ -38,17 +38,23 @@ Idempotency: retaining state is checked by `battleId` before insertion. An ident Projections are always fully regenerated from repository-tracked inputs, never incrementally updated — this is what keeps `AC-RDA-003` true (identical output whether facts come from `results/raw` or archived `results/rollups`, and always reflecting only currently-registered, unbanned, non-excluded, non-disqualified data): 1. Load every raw fact and rollup record; filter to those whose submitting account is currently registered for that client ID and not banned, whose `battleId` is not in `exclusions.json`, and whose participants are not disqualified. -2. For each configured game type, compute each eligible bot's APS (mean, across its distinct pairings, of the mean per-pairing score share) and battle/pairing counts, and matchmaking advice (pairings below `TARGET_SAMPLES_PER_PAIRING`, tagged `new-bot` at zero samples or `under-sampled` otherwise) — team pairs that would share a member (`CAP-002`) are never proposed. +2. For each configured game type and the current behavior version, select exact catalog identities whose status is `active` and whose team shape matches the game type, then discard any matchup containing another identity. For each remaining battle, divide a participant's `totalScore` by the total of every participant's score, using zero when that total is zero. Group shares by the exact sorted participant identity set, average repeated samples within each set, then set APS to 100 times the unweighted mean of those pairing averages. Store four decimal places, sort by descending APS and stable case-folded identity, and give an active entry with no samples APS zero. Compute matchmaking advice for pairs below `TARGET_SAMPLES_PER_PAIRING`, tagged `new-bot` at zero samples or `under-sampled` otherwise; team pairs that share a member (`CAP-002`) are never proposed. 3. Write `leaderboard/.json`, `leaderboard/bots/-.json`, `matchmaking/pairings-.json`, `matchmaking/matches_needed-.json`, mirrored copies of the leaderboard and bot-detail files under `site/data/`, and `clients.json` (battle totals per client ID). Every projection carries a `projectionId` — a content hash of the game type, behavior version, contributing records, and catalog — so a consumer can tell whether two projections were derived from the same inputs. `scripts/compact.py` moves facts older than three full months into monthly rollups on a separate `archive` branch checkout, but only after confirming aggregation output is byte-identical before and after the move; any mismatch rolls the move back entirely (`AC-RDA-003`). +## Publication and rollover (`scripts/publication.py`) + +Aggregation deliberately owns no clock. `publication.py` hashes the complete ranking-visible `site/data/leaderboard/*.json` and `site/data/bots/*.json` tree and records that value in `site/data/history.json`. It advances `lastUpdatedAt` only when the regenerated hash differs from the last published hash, which also detects ranking data regenerated by a moderation pull request outside the publisher. + +The result and catalog workflows share the `rumble-publication-writer` concurrency group. Before either reads new external input, `rollover` checks `currentMonth`. On the first run in a later UTC month it copies current leaderboard and bot-detail bytes to `site/data/snapshots//`, adds the immutable manifest entry, and advances the month cursor. Multiple missed months copy the same last published state. Existing destination content must match byte for byte or rollover fails. + ## Ordering guarantee `.github/workflows/ingest.yml` always pushes accepted facts and regenerated projections before it publishes per-record receipt comments and closes the issue, so a contributor is never told "accepted" for a fact that is not yet durably committed. ## Dashboard (`site/`) -The static site has no server component: `site/app.js` fetches `data/leaderboard/${gameType}.json` and `data/bots/...json` directly and renders/sorts entries client-side (`AC-RDA-004`). `.github/workflows/pages.yml` redeploys whenever a push changes anything under `site/`. +The static site has no server component. `site/app.js` first reads `data/history.json`, then fetches the current `data/leaderboard/${gameType}.json` and bot details or the same paths below the selected snapshot prefix. It labels archived periods read-only and shows the selected ranking's update time. A changed writer commit explicitly dispatches `.github/workflows/pages.yml`; an hourly comparison with the last successful Pages workflow run repairs a missed deployment without deploying an identical `site/` tree. diff --git a/docs/capabilities/CAP-002-bot-catalog-sync/README.md b/docs/capabilities/CAP-002-bot-catalog-sync/README.md index f8c8e9e..3083c4a 100644 --- a/docs/capabilities/CAP-002-bot-catalog-sync/README.md +++ b/docs/capabilities/CAP-002-bot-catalog-sync/README.md @@ -11,7 +11,7 @@ title: Bot catalog synchronization # CAP-002 — Bot catalog synchronization -What the system can do: keep a local, read-only copy of the reviewed Rumble bot catalog (`catalog.json`) synchronized from its declared external HTTPS source, normalizing and validating team membership so that only bots and teams eligible for ranked play can ever reach `CAP-001`'s validation and matchmaking. +What the system can do: check a reviewed external Rumble bot catalog hourly, update the local read-only copy only when normalized content changes, and immediately publish the active bot and version set without doing ranking or deployment work for an identical poll. This capability exists so `CAP-001` never has to trust unvalidated team data: `scripts/sync_catalog.py` is the sole writer of `catalog.json`, and `scripts/common.py::normalized_catalog_bots` is the shared team-membership contract both this capability and `CAP-001` rely on. diff --git a/docs/capabilities/CAP-002-bot-catalog-sync/criteria.md b/docs/capabilities/CAP-002-bot-catalog-sync/criteria.md index 782b783..6372fc7 100644 --- a/docs/capabilities/CAP-002-bot-catalog-sync/criteria.md +++ b/docs/capabilities/CAP-002-bot-catalog-sync/criteria.md @@ -11,10 +11,12 @@ ac-prefix: RBC This capability's criterion ID canonicalizes its test tag the same way `CAP-001`'s do (`RBC004` → `RBC-004`); see `../CAP-001-ranked-result-pipeline/criteria.md` for the evidence-language note. +Both criteria carry `@draft` for the same unsupported-Python evidence reason; their positive and negative tests run in `verify.yml`. + The `RBC-001`..`RBC-003` numbers are not used by any current test or corpus artifact — this is a normal gap, not a missing criterion; a future one would mint `RBC-005` next. ```gherkin -@RBC-004 +@RBC-004 @draft Scenario: Synchronization keeps team membership valid and never advises invalid teams Test-type: Integration Given a source catalog with individual bots and additive team entries @@ -32,3 +34,19 @@ Evidence (negative): - `testRBC004_IntegrationNegative_catalog_sync_rejects_unknown_team_member` - `testRBC004_IntegrationNegative_teams_sharing_a_member_are_never_advised` + +```gherkin +@RBC-005 @draft +Scenario: Scheduled catalog polling publishes only a changed reviewed catalog +Test-type: Integration + Given the normalized local catalog and the reviewed source catalog + When the hourly synchronization runs + Then changed source content is stored and immediately regenerated into current rankings + And identical source content is not rewritten, aggregated, committed, or deployed + And catalog and result writers cannot overlap their publication sections +``` + +Evidence: + +- `testRBC005_IntegrationPositive_changed_catalog_is_written_for_publication` +- `testRBC005_IntegrationNegative_unchanged_catalog_skips_rewrite_and_aggregation` diff --git a/docs/capabilities/CAP-002-bot-catalog-sync/design.md b/docs/capabilities/CAP-002-bot-catalog-sync/design.md index 497b13e..ba4060b 100644 --- a/docs/capabilities/CAP-002-bot-catalog-sync/design.md +++ b/docs/capabilities/CAP-002-bot-catalog-sync/design.md @@ -16,6 +16,6 @@ title: Bot catalog synchronization — design - among entries with `status: active`, no two may share the same `name`+`version` identity; - every member named in an active team's `teamMembers` must itself be an active, non-team catalog entry. -Any violation raises before the write happens, so `catalog.json` can never hold an internally inconsistent catalog (`AC-RBC-004`, negative direction). Because `CAP-001`'s validation and aggregation re-read `catalog.json` fresh on every run, a newly synchronized, eligible bot can submit and be ranked immediately — there is no separate activation step (`AC-RDA-001`'s `catalog_sync_admits_published_bot_results` facet). +Any violation raises before the write happens, so `catalog.json` can never hold an internally inconsistent catalog (`AC-RBC-004`, negative direction). Because `CAP-001`'s validation and aggregation re-read `catalog.json` fresh on every changed synchronization, a newly synchronized, eligible bot can submit and be ranked immediately — there is no separate activation step (`AC-RDA-001`'s `catalog_sync_admits_published_bot_results` facet). -`.github/workflows/sync-catalog.yml` runs synchronization on its own schedule (23 minutes past every UTC hour, per `GOVERNANCE.md`), then immediately regenerates projections in the same run, so a catalog change and its effect on eligibility and matchmaking land in the same commit. +`.github/workflows/sync-catalog.yml` checks the source at 23 minutes past every UTC hour. `sync_catalog.sync` compares normalized objects before writing. The workflow uses the resulting tracked-file difference to skip aggregation when nothing changed; a new bot, a new version, or any other reviewed source change immediately regenerates projections so the catalog and its eligibility effect land in one commit. It shares `rumble-publication-writer` concurrency with result ingestion so both paths can safely run month rollover before reading new input. diff --git a/docs/decisions/ADR-001-immutable-content-addressed-facts.md b/docs/decisions/ADR-001-immutable-content-addressed-facts.md deleted file mode 100644 index b69fd79..0000000 --- a/docs/decisions/ADR-001-immutable-content-addressed-facts.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -id: ADR-001 -type: decision -status: inferred -author: agent -accepted-by: [] -links: [CAP-001] -title: Accepted results are immutable, content-addressed facts; every projection is disposable and fully regenerated ---- - -# ADR-001 — Accepted results are immutable, content-addressed facts; every projection is disposable and fully regenerated - -Accepted battle results are stored as individual JSON files under `results/raw///.json`, never edited or deleted once written. The leaderboard, matchmaking advice, per-client totals, and dashboard data are always fully rebuilt from these facts (plus current catalog, registration, and moderation state) rather than incrementally updated, and CI fails a pull request whose regenerated output differs from what is committed (`.github/workflows/verify.yml`). - -This constrains future work: a change must never introduce a code path that edits or deletes an accepted fact, and must never let a projection be written by anything other than full regeneration from tracked inputs — both would break the audit guarantee this repository exists to provide (`G-001`). - -**Why:** a public, multi-contributor leaderboard needs a record nobody can quietly rewrite, and a ranking anyone can reproduce rather than take on faith. Content-addressed filenames give idempotent, collision-safe writes for free (a retried submission naturally resolves to the same fact) without needing a separate deduplication index. Making projections disposable and CI-verified against drift is what lets moderation (bans, exclusions, catalog changes) take effect by re-deriving the present rather than editing the past. diff --git a/docs/decisions/ADR-003-immutable-facts-and-monthly-publication-snapshots.md b/docs/decisions/ADR-003-immutable-facts-and-monthly-publication-snapshots.md new file mode 100644 index 0000000..40b7d02 --- /dev/null +++ b/docs/decisions/ADR-003-immutable-facts-and-monthly-publication-snapshots.md @@ -0,0 +1,20 @@ +--- +id: ADR-003 +type: decision +status: inferred +author: agent +accepted-by: [] +links: [CAP-001, CAP-002] +supersedes: [ADR-001] +title: Keep immutable facts and cumulative rankings with immutable monthly publication snapshots +--- + +# ADR-003 — Keep immutable facts and cumulative rankings with immutable monthly publication snapshots + +Accepted battle results remain content-addressed immutable facts. The current leaderboard, matchmaking advice, client totals, and current dashboard JSON remain disposable projections fully regenerated from tracked facts plus current catalog, registration, engine, and moderation state. + +The live ranking is cumulative and does not reset at month boundaries. Before the first serialized writer accepts new input in a new UTC month, it copies the previous current leaderboard and bot details byte for byte into an immutable snapshot for each completed month. Late accepted results can change the current projection but never rewrite a snapshot. + +`site/data/history.json` separates operational publication metadata from deterministic aggregation. It records a hash of the complete current ranking-visible tree, and its `lastUpdatedAt` changes only when that hash changes; polling, aggregation with identical output, deployment, and snapshot creation alone do not make the ranking appear fresher. + +This distinction keeps rankings reproducible while preserving what viewers actually saw at month end. It also constrains every writer to the shared serialization boundary and every future migration to treat existing snapshot paths as append-only records. The system structure and shared rollover flow are described in [the architecture overview](../architecture/README.md) and [the design overview](../design/README.md). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 75ffb3b..353652b 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -21,6 +21,6 @@ Decision records are timeless: state what is decided and only the enduring conte A decision that changes a methodology contract inventories every live carrier that states the affected contract and updates that complete inventory in the same change. Live carriers include current corpus truth, canonical and generated skills, templates, public or contributor guidance, implementation explanations, CLI text, and distribution metadata. Historical analyses, completed plans, and changelog entries remain pinned history. Focused guards hold stable repaired claims, but no current mechanism derives an arbitrary contract's complete carrier set, so the general obligation remains agent-enforced. -- [ADR-001 — Accepted results are immutable, content-addressed facts; every projection is disposable and fully regenerated](ADR-001-immutable-content-addressed-facts.md) · `inferred` — Accepted battle results are stored as individual JSON files under `results/raw///.json`, never edited or deleted once written. - [ADR-002 — GitHub Issues, not a dedicated API, is the result-submission transport](ADR-002-github-issues-as-submission-transport.md) · `inferred` — A client submits a result batch by opening a GitHub issue with a fixed title shape, the `result-submission` label, and one fenced JSON envelope in the body; ingestion runs as a GitHub Actions… +- [ADR-003 — Keep immutable facts and cumulative rankings with immutable monthly publication snapshots](ADR-003-immutable-facts-and-monthly-publication-snapshots.md) · `inferred` — Accepted battle results remain content-addressed immutable facts. diff --git a/docs/design/README.md b/docs/design/README.md index fd05627..1bd12aa 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -13,13 +13,16 @@ sequenceDiagram participant WF as ingest.yml participant Val as validate.py participant Ing as ingest.py + participant Pub as publication.py participant Agg as aggregate.py Client->>GH: open issue, apply result-submission label GH->>WF: issues: opened/labeled event WF->>WF: extract_envelope.py (one fenced JSON block) + WF->>Pub: snapshot completed UTC months WF->>Val: validate each record independently WF->>Ing: persist each accepted record (content-addressed) - WF->>Agg: regenerate all projections + WF->>Pub: regenerate current projections + Pub->>Agg: deterministic aggregation WF->>GH: git commit + push (facts and projections together) WF->>GH: comment per-record receipt, then close issue ``` @@ -28,11 +31,11 @@ The commit-and-push step always happens before the receipt comment and issue clo ### 2. Scheduled fallback -The same workflow also runs on a `17,47 * * * *` cron and on manual dispatch, draining every currently labelled, still-open issue rather than just the one that triggered it. This is what makes the label — not the triggering event — the actual unit of work, and it is why the workflow's `concurrency` group serializes runs instead of allowing them to race. +The same workflow also runs on a `17,47 * * * *` cron and on manual dispatch, draining every currently labelled, still-open issue rather than just the one that triggered it. This is what makes the label — not the triggering event — the actual unit of work. Its `rumble-publication-writer` concurrency group is shared with catalog synchronization so neither path can race publication or month rollover. ### 3. Catalog synchronization -A separate `23 * * * *` cron fetches the external catalog source, normalizes and validates team membership (`CAP-002`), writes `catalog.json`, and immediately regenerates every projection in the same run — so a newly eligible bot's ranking eligibility and a removed bot's disappearance from matchmaking land in one commit. +A separate `23 * * * *` cron fetches the external catalog source and normalizes and validates team membership (`CAP-002`). If normalized content is unchanged, the workflow skips aggregation, commit, and deployment. If it changed, the workflow writes `catalog.json` and immediately regenerates every projection in the same run, so a newly eligible bot's zero-sample ranking and a superseded bot's disappearance land in one commit. ### 4. Moderation (reapply, never rewrite) @@ -46,15 +49,21 @@ On the first drain of a month, `scripts/compact.py` moves facts older than three `.github/workflows/verify.yml` runs the unit test suite, then regenerates every projection and fails the build (`git diff --exit-code`) if the regenerated output differs from what is committed. Because `leaderboard/`, `matchmaking/`, `clients.json`, and `site/data/` are declared disposable and always derived, this single check is what actually enforces "generated projections are always current," on every pull request and every push to `main` — including moderator PRs that only touch `bans.json` or `exclusions.json`. -### 7. Dashboard publication +### 7. Ranking publication and month rollover -`.github/workflows/pages.yml` deploys `site/` to GitHub Pages whenever a push changes anything under `site/**`; the dashboard itself makes no server calls beyond fetching the generated JSON files that ingestion and catalog sync already committed (`CAP-001`, `AC-RDA-004`). +Both writers call `publication.py` before reading new external input. The first writer in a later UTC month copies the previous cumulative current leaderboard and bot-detail JSON byte for byte into every missing `site/data/snapshots/YYYY-MM/` path, updates `site/data/history.json`, and refuses any overwrite. Current rankings never reset, and a late result changes only current data. + +After deterministic aggregation, publication compares current ranking bytes and advances `lastUpdatedAt` only for a visible ranking change. A catalog poll, identical aggregation, snapshot, workflow, or deploy does not make the ranking look newer. + +### 8. Dashboard deployment + +Workflow commits made with GitHub's built-in token do not trigger another push workflow, so each writer explicitly dispatches `.github/workflows/pages.yml` after committing a changed `site/` tree. An hourly scheduled fallback compares `site/` with the head commit of the latest successful Pages run and deploys only when they differ. The dashboard itself makes no server calls beyond fetching the current or selected snapshot JSON (`CAP-001`, `RDA-004`, `RDA-009`). ## Cross-cutting patterns - **Independent per-record validation** — a batch's records are never accepted or rejected as a unit; this is what lets `CAP-001` guarantee that one contributor's malformed record cannot cost their other, valid results. - **Content addressing for idempotency** — the same pattern (SHA-256 of canonical JSON) secures both fact deduplication in `CAP-001` and dedup-safe re-ingestion after a client retry; see `common.py`. -- **Disposable, fully-regenerated derived data, guarded by drift detection** — no projection is ever partially updated; `verify.yml`'s diff check is the cross-cutting mechanism that keeps this true in practice, not just in intent. +- **Disposable current projections and immutable publication history** — current data is never partially updated; `verify.yml` checks deterministic regeneration and rejects modification or deletion of a snapshot that already exists on the base branch. - **CI as sole writer, humans as sole policy-changers** — every routine data change flows through a workflow; every policy or catalog-eligibility change flows through a human-reviewed pull request (`C-002`). diff --git a/leaderboard/1v1.json b/leaderboard/1v1.json index 467bc0a..6e04eaf 100644 --- a/leaderboard/1v1.json +++ b/leaderboard/1v1.json @@ -58,6 +58,6 @@ } ], "gameType": "1v1", - "projectionId": "d40ed7bbaed6c93f494cdf6e4aac78dd26a7a9b2ff24395f137dc6f4b3fcb293", + "projectionId": "ac5f143aca124b1ec18b059698918e4f6f3f0a217b6361e4c261a15c09cc3ce8", "schemaVersion": 1 } diff --git a/leaderboard/bots/Orbit-1.0.2.json b/leaderboard/bots/Orbit-1.0.2.json deleted file mode 100644 index f4a9de0..0000000 --- a/leaderboard/bots/Orbit-1.0.2.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "entry": { - "aps": 0.0, - "battles": 0, - "bot": "Orbit 1.0.2", - "epoch": 1, - "name": "Orbit", - "owner": "flemming-n-larsen", - "pairings": 0, - "platform": "Python", - "version": "1.0.2" - }, - "gameType": "melee", - "projectionId": "637b218831c22de1bfc2dcc62bc8f7ac29c1f8821b79359e66128cac13ec1d28", - "schemaVersion": 1 -} diff --git a/leaderboard/bots/Vector-1.0.0.json b/leaderboard/bots/Vector-1.0.0.json deleted file mode 100644 index 87f5866..0000000 --- a/leaderboard/bots/Vector-1.0.0.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "entry": { - "aps": 0.0, - "battles": 0, - "bot": "Vector 1.0.0", - "epoch": 1, - "name": "Vector", - "owner": "flemming-n-larsen", - "pairings": 0, - "platform": "Python", - "version": "1.0.0" - }, - "gameType": "melee", - "projectionId": "637b218831c22de1bfc2dcc62bc8f7ac29c1f8821b79359e66128cac13ec1d28", - "schemaVersion": 1 -} diff --git a/matchmaking/matches_needed-1v1.json b/matchmaking/matches_needed-1v1.json index a8746b3..c4670ff 100644 --- a/matchmaking/matches_needed-1v1.json +++ b/matchmaking/matches_needed-1v1.json @@ -82,7 +82,7 @@ "reason": "new-bot" } ], - "projectionId": "d40ed7bbaed6c93f494cdf6e4aac78dd26a7a9b2ff24395f137dc6f4b3fcb293", + "projectionId": "ac5f143aca124b1ec18b059698918e4f6f3f0a217b6361e4c261a15c09cc3ce8", "schemaVersion": 1, "targetSamplesPerPairing": 6 } diff --git a/matchmaking/pairings-1v1.json b/matchmaking/pairings-1v1.json index ca4b828..7aa7e38 100644 --- a/matchmaking/pairings-1v1.json +++ b/matchmaking/pairings-1v1.json @@ -1,14 +1,6 @@ { "gameType": "1v1", - "pairings": [ - { - "battles": 2, - "bots": [ - "Orbit 1.0.2", - "Vector 1.0.0" - ] - } - ], - "projectionId": "d40ed7bbaed6c93f494cdf6e4aac78dd26a7a9b2ff24395f137dc6f4b3fcb293", + "pairings": [], + "projectionId": "ac5f143aca124b1ec18b059698918e4f6f3f0a217b6361e4c261a15c09cc3ce8", "schemaVersion": 1 } diff --git a/scripts/aggregate.py b/scripts/aggregate.py index c179e3a..9e4fcc1 100644 --- a/scripts/aggregate.py +++ b/scripts/aggregate.py @@ -60,7 +60,10 @@ def facts(root: Path) -> list[dict[str, Any]]: def active_catalog(root: Path) -> list[dict[str, Any]]: """Return active bot versions in stable identity order.""" - return sorted((bot for bot in read_json(root / "catalog.json").get("bots", []) if bot.get("status") == "active"), key=lambda item: (str(item.get("name")).casefold(), str(item.get("version")))) + return sorted( + (bot for bot in read_json(root / "catalog.json").get("bots", []) if bot.get("status") == "active"), + key=lambda item: (str(item.get("name")).casefold(), str(item.get("name")), str(item.get("version")).casefold(), str(item.get("version"))), + ) def identity(participant: dict[str, Any]) -> tuple[str, str]: @@ -74,9 +77,15 @@ def aggregate_game_type(records: list[dict[str, Any]], catalog: list[dict[str, A eligible = { (str(bot["name"]), str(bot["version"])): bot for bot in catalog - if bool(bot.get("teamMembers", [])) is expects_team + if bot.get("status") == "active" and bool(bot.get("teamMembers", [])) is expects_team } - relevant = [record for record in records if record.get("gameType") == game_type and record.get("engine", {}).get("behaviorVersion") == behavior_version] + relevant = [ + record + for record in records + if record.get("gameType") == game_type + and record.get("engine", {}).get("behaviorVersion") == behavior_version + and all(identity(participant) in eligible for participant in record.get("participants", [])) + ] shares: dict[tuple[str, str], dict[tuple[tuple[str, str], ...], list[float]]] = defaultdict(lambda: defaultdict(list)) pairing_counts: dict[tuple[tuple[str, str], ...], int] = defaultdict(int) pair_sample_counts: dict[tuple[tuple[str, str], tuple[str, str]], int] = defaultdict(int) @@ -100,7 +109,7 @@ def aggregate_game_type(records: list[dict[str, Any]], catalog: list[dict[str, A "aps": round((sum(per_pairing) / len(per_pairing) * 100) if per_pairing else 0.0, 4), "battles": sum(len(values) for values in bot_pairings.values()), "pairings": len(bot_pairings), "epoch": behavior_version, }) - entries.sort(key=lambda item: (-float(item["aps"]), str(item["bot"]).casefold())) + entries.sort(key=lambda item: (-float(item["aps"]), str(item["bot"]).casefold(), str(item["bot"]))) projection_id = content_hash({"gameType": game_type, "behaviorVersion": behavior_version, "records": relevant, "catalog": catalog}) leaderboard = {"schemaVersion": 1, "projectionId": projection_id, "gameType": game_type, "behaviorVersion": behavior_version, "entries": entries} pairs = [{"bots": [f"{name} {version}" for name, version in pair], "battles": count} for pair, count in sorted(pairing_counts.items())] @@ -121,6 +130,10 @@ def aggregate(root: Path) -> None: engine = read_json(root / "engine.json") behavior_version = int(engine["behaviorVersion"]) records, catalog = facts(root), active_catalog(root) + for directory in (root / "leaderboard" / "bots", root / "site" / "data" / "bots"): + if directory.exists(): + for path in directory.glob("*.json"): + path.unlink() for game_type in sorted(engine["gameTypes"]): leaderboard, pairings, needed = aggregate_game_type(records, catalog, game_type, behavior_version) write_json(root / "leaderboard" / f"{game_type}.json", leaderboard) diff --git a/scripts/check_snapshots.py b/scripts/check_snapshots.py new file mode 100644 index 0000000..dc1fbfa --- /dev/null +++ b/scripts/check_snapshots.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Reject pull requests that modify or delete an existing ranking snapshot.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from typing import Any + +HISTORY_PATH = "site/data/history.json" + + +def changed_snapshots(base: str) -> list[str]: + """Return non-additive snapshot changes relative to a git base.""" + result = subprocess.run( + ["git", "diff", "--name-status", f"{base}...HEAD", "--", "site/data/snapshots"], + check=True, + capture_output=True, + text=True, + ) + violations = [] + for line in result.stdout.splitlines(): + status, _, path = line.partition("\t") + if status != "A": + violations.append(path or line) + return violations + + +def manifest_snapshots(revision: str) -> list[Any] | None: + """Return the history manifest's snapshot entries at a revision, or None when it has no manifest.""" + result = subprocess.run( + ["git", "show", f"{revision}:{HISTORY_PATH}"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + value = json.loads(result.stdout) + snapshots = value.get("snapshots") if isinstance(value, dict) else None + return snapshots if isinstance(snapshots, list) else [] + + +def changed_manifest_entries(base_entries: list[Any] | None, head_entries: list[Any] | None) -> list[str]: + """Return base manifest snapshot entries that the head manifest no longer carries unchanged.""" + head = head_entries or [] + violations = [] + for entry in base_entries or []: + if entry not in head: + month = entry.get("month") if isinstance(entry, dict) else None + violations.append(f"{HISTORY_PATH} snapshot entry {month or json.dumps(entry)}") + return violations + + +def main() -> int: + """Check snapshot changes from the command line.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True) + arguments = parser.parse_args() + violations = changed_snapshots(arguments.base) + merge_base = subprocess.run( + ["git", "merge-base", arguments.base, "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + violations += changed_manifest_entries(manifest_snapshots(merge_base), manifest_snapshots("HEAD")) + if violations: + print("existing snapshots are immutable:") + for path in violations: + print(f"- {path}") + return 1 + print("snapshot changes are additive") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publication.py b/scripts/publication.py new file mode 100644 index 0000000..b453f4c --- /dev/null +++ b/scripts/publication.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Publish changed current rankings and immutable cumulative month snapshots.""" + +from __future__ import annotations + +import argparse +import hashlib +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from aggregate import aggregate +from common import read_json, write_json + +CURRENT_DATA_DIRECTORIES = ("leaderboard", "bots") + + +def utc_now() -> datetime: + """Return the current aware UTC time.""" + return datetime.now(timezone.utc) + + +def parse_instant(value: str | None) -> datetime: + """Parse an optional ISO-8601 instant, defaulting to now.""" + if value is None: + return utc_now() + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("publication time must include a UTC offset") + return parsed.astimezone(timezone.utc) + + +def instant_text(value: datetime) -> str: + """Return the canonical second-precision UTC timestamp.""" + return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def month_text(value: datetime) -> str: + """Return the UTC calendar month for an instant.""" + return value.astimezone(timezone.utc).strftime("%Y-%m") + + +def next_month(value: str) -> str: + """Advance a canonical YYYY-MM value by one month.""" + year, month = (int(part) for part in value.split("-")) + return f"{year + (month == 12):04d}-{1 if month == 12 else month + 1:02d}" + + +def current_files(root: Path) -> dict[str, bytes]: + """Return current public ranking files by data-relative path.""" + data_root = root / "site" / "data" + files: dict[str, bytes] = {} + for directory_name in CURRENT_DATA_DIRECTORIES: + directory = data_root / directory_name + if directory.exists(): + for path in sorted(directory.glob("*.json")): + files[path.relative_to(data_root).as_posix()] = path.read_bytes() + return files + + +def ranking_hash(files: dict[str, bytes]) -> str: + """Return a stable hash of the complete current public ranking tree.""" + digest = hashlib.sha256() + for relative, content in sorted(files.items()): + digest.update(relative.encode("utf-8")) + digest.update(b"\0") + digest.update(content) + digest.update(b"\0") + return digest.hexdigest() + + +def history(root: Path) -> dict[str, Any]: + """Read publication history or return its initial state.""" + path = root / "site" / "data" / "history.json" + if not path.exists(): + return {"schemaVersion": 1, "currentMonth": None, "currentDataHash": None, "lastUpdatedAt": None, "snapshots": []} + value = read_json(path) + if not isinstance(value, dict) or value.get("schemaVersion") != 1 or not isinstance(value.get("snapshots"), list): + raise ValueError("site/data/history.json has an unsupported schema") + return value + + +def write_history(root: Path, value: dict[str, Any]) -> None: + """Write the public history manifest.""" + write_json(root / "site" / "data" / "history.json", value) + + +def rollover(root: Path, at: datetime) -> bool: + """Snapshot each completed UTC month without ever rewriting an archive.""" + manifest = history(root) + target_month = month_text(at) + current_month = manifest.get("currentMonth") + if current_month is None: + manifest["currentMonth"] = target_month + write_history(root, manifest) + return True + if not isinstance(current_month, str) or len(current_month) != 7: + raise ValueError("history currentMonth must be YYYY-MM or null") + if current_month > target_month: + raise ValueError("publication time precedes history currentMonth") + + changed = False + while current_month < target_month: + relative_files = current_files(root) + snapshot_root = root / "site" / "data" / "snapshots" / current_month + expected_paths = {snapshot_root / relative for relative in relative_files} + existing_paths = set(snapshot_root.rglob("*.json")) if snapshot_root.exists() else set() + if not existing_paths.issubset(expected_paths): + raise ValueError(f"snapshot {current_month} is immutable: unexpected files exist") + for relative, content in relative_files.items(): + destination = snapshot_root / relative + if destination.exists() and destination.read_bytes() != content: + raise ValueError(f"snapshot {current_month} is immutable: {relative} differs") + if not destination.exists(): + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) + + entry = {"month": current_month, "updatedAt": manifest.get("lastUpdatedAt"), "path": f"data/snapshots/{current_month}"} + entries = [item for item in manifest["snapshots"] if isinstance(item, dict) and item.get("month") == current_month] + if entries and entries[0] != entry: + raise ValueError(f"snapshot manifest entry for {current_month} is immutable") + if not entries: + manifest["snapshots"].append(entry) + current_month = next_month(current_month) + changed = True + + if manifest.get("currentMonth") != target_month: + manifest["currentMonth"] = target_month + changed = True + if changed: + manifest["snapshots"] = sorted(manifest["snapshots"], key=lambda item: item["month"]) + write_history(root, manifest) + return changed + + +def publish_current(root: Path, at: datetime) -> bool: + """Regenerate current projections and timestamp only visible ranking changes.""" + before = current_files(root) + aggregate(root) + after = current_files(root) + manifest = history(root) + after_hash = ranking_hash(after) + recorded_hash = manifest.get("currentDataHash") + changed = after != before if recorded_hash is None else after_hash != recorded_hash + if recorded_hash is None and not changed: + manifest["currentDataHash"] = after_hash + write_history(root, manifest) + if changed: + manifest["currentDataHash"] = after_hash + manifest["lastUpdatedAt"] = instant_text(at) + write_history(root, manifest) + return changed + + +def main() -> int: + """Run rollover and optionally regenerate current rankings.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--at", help="ISO-8601 publication time; defaults to the current UTC time") + parser.add_argument("--rollover-only", action="store_true") + arguments = parser.parse_args() + root = arguments.root.resolve() + at = parse_instant(arguments.at) + rolled_over = rollover(root, at) + ranking_changed = False if arguments.rollover_only else publish_current(root, at) + print(f"rollover_changed={str(rolled_over).lower()}") + print(f"ranking_changed={str(ranking_changed).lower()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/sync_catalog.py b/scripts/sync_catalog.py index eeea559..6773336 100644 --- a/scripts/sync_catalog.py +++ b/scripts/sync_catalog.py @@ -48,10 +48,15 @@ def synchronized_catalog(catalog: dict[str, Any], fetch: Fetch = fetch_source) - } -def sync(root: Path, fetch: Fetch = fetch_source) -> None: - """Refresh catalog.json from its declared generated source.""" +def sync(root: Path, fetch: Fetch = fetch_source) -> bool: + """Refresh catalog.json and report whether its normalized content changed.""" catalog_path = root / "catalog.json" - write_json(catalog_path, synchronized_catalog(read_json(catalog_path), fetch)) + current = read_json(catalog_path) + synchronized = synchronized_catalog(current, fetch) + if synchronized == current: + return False + write_json(catalog_path, synchronized) + return True def main() -> int: @@ -60,11 +65,11 @@ def main() -> int: parser.add_argument("--root", type=Path, required=True) arguments = parser.parse_args() try: - sync(arguments.root.resolve()) + changed = sync(arguments.root.resolve()) except (OSError, ValueError) as error: print(f"catalog synchronization failed: {error}") return 1 - print("synchronized catalog") + print("synchronized catalog" if changed else "catalog unchanged") return 0 diff --git a/site/app.js b/site/app.js index 316ad1f..225c62f 100644 --- a/site/app.js +++ b/site/app.js @@ -1,38 +1,79 @@ -const select = document.querySelector('#game-type'); +const gameTypeSelect = document.querySelector('#game-type'); +const periodSelect = document.querySelector('#ranking-period'); +const archiveNotice = document.querySelector('#archive-notice'); const status = document.querySelector('#status'); const body = document.querySelector('#leaderboard'); let entries = []; +let entriesPrefix = 'data'; +let latestLeaderboardRequest = 0; +let publicationHistory = { lastUpdatedAt: null, snapshots: [] }; let sortField = 'aps'; let descending = true; +function selectedSnapshot() { + return publicationHistory.snapshots.find(snapshot => snapshot.month === periodSelect.value); +} + +function dataPrefix() { + const snapshot = selectedSnapshot(); + return snapshot ? snapshot.path : 'data'; +} + 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 = `${index + 1}${entry.bot}${entry.aps.toFixed(2)}${entry.battles}${entry.pairings}`; + row.innerHTML = `${index + 1}${entry.bot}${entry.aps.toFixed(2)}${entry.battles}${entry.pairings}`; body.append(row); }); } async function loadLeaderboard() { - const gameType = select.value; + const request = ++latestLeaderboardRequest; + const gameType = gameTypeSelect.value; + const snapshot = selectedSnapshot(); + const prefix = dataPrefix(); status.textContent = 'Loading leaderboard…'; + archiveNotice.hidden = !snapshot; try { - const response = await fetch(`data/leaderboard/${gameType}.json`); + const response = await fetch(`${prefix}/leaderboard/${gameType}.json`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); + // A slower response for an earlier selection must not replace the current one. + if (request !== latestLeaderboardRequest) return; entries = data.entries; + entriesPrefix = prefix; renderEntries(); - status.textContent = `${data.entries.length} active bots · behavior version ${data.behaviorVersion}`; + const updatedAt = snapshot ? snapshot.updatedAt : publicationHistory.lastUpdatedAt; + const updatedText = updatedAt ? ` · ranking data last updated ${new Date(updatedAt).toLocaleString()}` : ''; + status.textContent = `${data.entries.length} active bots · behavior version ${data.behaviorVersion}${updatedText}`; } catch (error) { + if (request !== latestLeaderboardRequest) return; status.textContent = `The leaderboard is unavailable: ${error.message}`; } } -select.addEventListener('change', loadLeaderboard); +async function loadHistory() { + try { + const response = await fetch('data/history.json'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + publicationHistory = await response.json(); + [...publicationHistory.snapshots].reverse().forEach(snapshot => { + const option = document.createElement('option'); + option.value = snapshot.month; + option.textContent = snapshot.month; + periodSelect.append(option); + }); + } catch (error) { + status.textContent = `Ranking history is unavailable: ${error.message}`; + } +} + +gameTypeSelect.addEventListener('change', loadLeaderboard); +periodSelect.addEventListener('change', loadLeaderboard); document.querySelectorAll('[data-sort]').forEach(button => button.addEventListener('click', () => { if (sortField === button.dataset.sort) descending = !descending; else { sortField = button.dataset.sort; descending = true; } renderEntries(); })); -loadLeaderboard(); +loadHistory().then(loadLeaderboard); diff --git a/site/data/bots/Orbit-1.0.2.json b/site/data/bots/Orbit-1.0.2.json deleted file mode 100644 index f4a9de0..0000000 --- a/site/data/bots/Orbit-1.0.2.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "entry": { - "aps": 0.0, - "battles": 0, - "bot": "Orbit 1.0.2", - "epoch": 1, - "name": "Orbit", - "owner": "flemming-n-larsen", - "pairings": 0, - "platform": "Python", - "version": "1.0.2" - }, - "gameType": "melee", - "projectionId": "637b218831c22de1bfc2dcc62bc8f7ac29c1f8821b79359e66128cac13ec1d28", - "schemaVersion": 1 -} diff --git a/site/data/bots/Vector-1.0.0.json b/site/data/bots/Vector-1.0.0.json deleted file mode 100644 index 87f5866..0000000 --- a/site/data/bots/Vector-1.0.0.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "entry": { - "aps": 0.0, - "battles": 0, - "bot": "Vector 1.0.0", - "epoch": 1, - "name": "Vector", - "owner": "flemming-n-larsen", - "pairings": 0, - "platform": "Python", - "version": "1.0.0" - }, - "gameType": "melee", - "projectionId": "637b218831c22de1bfc2dcc62bc8f7ac29c1f8821b79359e66128cac13ec1d28", - "schemaVersion": 1 -} diff --git a/site/data/history.json b/site/data/history.json new file mode 100644 index 0000000..2704146 --- /dev/null +++ b/site/data/history.json @@ -0,0 +1,7 @@ +{ + "currentMonth": null, + "currentDataHash": "370ee0b5c0cc2ee1bd68b46478e08811f04f757ccb4732cdaca1acaf1512ab7c", + "lastUpdatedAt": "2026-09-13T11:36:34Z", + "schemaVersion": 1, + "snapshots": [] +} diff --git a/site/data/leaderboard/1v1.json b/site/data/leaderboard/1v1.json index 467bc0a..6e04eaf 100644 --- a/site/data/leaderboard/1v1.json +++ b/site/data/leaderboard/1v1.json @@ -58,6 +58,6 @@ } ], "gameType": "1v1", - "projectionId": "d40ed7bbaed6c93f494cdf6e4aac78dd26a7a9b2ff24395f137dc6f4b3fcb293", + "projectionId": "ac5f143aca124b1ec18b059698918e4f6f3f0a217b6361e4c261a15c09cc3ce8", "schemaVersion": 1 } diff --git a/site/index.html b/site/index.html index 45e3a57..f139353 100644 --- a/site/index.html +++ b/site/index.html @@ -9,11 +9,15 @@

Tank Royale Rumble

-

Reproducible rankings from immutable community-run battle facts.

- +

Reproducible rankings from immutable community-run battle facts. How APS and rankings work.

+
+ + +
+

Loading leaderboard…

- +
RankBot
RankBot

Contribute ranked battles

diff --git a/site/style.css b/site/style.css index 61e9ccd..33efe2f 100644 --- a/site/style.css +++ b/site/style.css @@ -1,5 +1,7 @@ :root { color-scheme: light dark; font-family: system-ui, sans-serif; } body { margin: 2rem auto; max-width: 64rem; padding: 0 1rem; } +.filters { display: flex; flex-wrap: wrap; gap: 1rem; } +.notice { border-left: .25rem solid #888; padding: .5rem .75rem; } table { border-collapse: collapse; margin-top: 1rem; width: 100%; } th, td { border-bottom: 1px solid #888; padding: .55rem; text-align: left; } th { font-weight: 700; } diff --git a/tests/test_rumble_data.py b/tests/test_rumble_data.py index 788c602..5adc58c 100644 --- a/tests/test_rumble_data.py +++ b/tests/test_rumble_data.py @@ -3,19 +3,21 @@ from __future__ import annotations import json -import shutil import sys import tempfile import unittest +from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) from aggregate import aggregate, aggregate_game_type +from check_snapshots import changed_manifest_entries from compact import compact from ingest import ingest -from sync_catalog import synchronized_catalog +from publication import publish_current, rollover +from sync_catalog import sync, synchronized_catalog from validate import ValidationError, engine_pin @@ -362,11 +364,194 @@ def testRDA004_E2EPositive_dashboard_references_versioned_projection_and_bot_det page = (ROOT / "site/index.html").read_text(encoding="utf-8") script = (ROOT / "site/app.js").read_text(encoding="utf-8") self.assertIn("game-type", page) - self.assertIn("data/leaderboard/${gameType}.json", script) - self.assertIn("data/bots/", script) + self.assertIn("${prefix}/leaderboard/${gameType}.json", script) + self.assertIn("${entriesPrefix}/bots/", script) self.assertIn("data-sort", page) self.assertIn("renderEntries", script) + def testRDA006_IntegrationPositive_aps_weights_each_distinct_pairing_equally(self) -> None: + catalog = json.loads((self.root / "catalog.json").read_text(encoding="utf-8"))["bots"] + records = [] + for _ in range(9): + records.append({"gameType": "1v1", "engine": {"behaviorVersion": 1}, "participants": [self.participant("Alpha", rank=1, total_score=100), self.participant("Bravo", rank=2, total_score=0)]}) + records.append({"gameType": "1v1", "engine": {"behaviorVersion": 1}, "participants": [self.participant("Alpha", rank=2, total_score=0), self.participant("Charlie", rank=1, total_score=100)]}) + + leaderboard, _, _ = aggregate_game_type(records, catalog, "1v1", behavior_version=1) + + alpha = next(entry for entry in leaderboard["entries"] if entry["name"] == "Alpha") + self.assertEqual(50.0, alpha["aps"]) + self.assertEqual(10, alpha["battles"]) + self.assertEqual(2, alpha["pairings"]) + + def testRDA006_IntegrationNegative_live_ranking_excludes_superseded_and_wrong_epoch_results(self) -> None: + catalog = json.loads((self.root / "catalog.json").read_text(encoding="utf-8"))["bots"] + catalog[0]["status"] = "superseded" + catalog.append({"name": "Alpha", "version": "2.0", "platform": "Python", "owner": "alpha-owner", "status": "active"}) + wrong_epoch = {"gameType": "1v1", "engine": {"behaviorVersion": 2}, "participants": [self.participant("Alpha", rank=1, total_score=100), self.participant("Bravo", rank=2, total_score=0)]} + + leaderboard, _, _ = aggregate_game_type([wrong_epoch], catalog, "1v1", behavior_version=1) + + identities = {(entry["name"], entry["version"]): entry for entry in leaderboard["entries"]} + self.assertNotIn(("Alpha", "1.0"), identities) + self.assertEqual(0.0, identities[("Alpha", "2.0")]["aps"]) + self.assertEqual(0.0, identities[("Bravo", "1.0")]["aps"]) + + def testRDA006_IntegrationPositive_equal_aps_uses_total_identity_order(self) -> None: + catalog = [ + {"name": "alpha", "version": "1.0", "status": "active"}, + {"name": "Alpha", "version": "1.0", "status": "active"}, + ] + + leaderboard, _, _ = aggregate_game_type([], catalog, "1v1", behavior_version=1) + + self.assertEqual(["Alpha 1.0", "alpha 1.0"], [entry["bot"] for entry in leaderboard["entries"]]) + + def testRDA007_IntegrationPositive_visible_ranking_change_advances_publication_time(self) -> None: + self.write("site/data/history.json", {"schemaVersion": 1, "currentMonth": "2026-09", "lastUpdatedAt": "2026-09-01T00:00:00Z", "snapshots": []}) + published_at = datetime(2026, 9, 13, 10, 30, tzinfo=timezone.utc) + + self.assertTrue(publish_current(self.root, published_at)) + + manifest = json.loads((self.root / "site/data/history.json").read_text(encoding="utf-8")) + self.assertEqual("2026-09-13T10:30:00Z", manifest["lastUpdatedAt"]) + + def testRDA007_IntegrationNegative_unchanged_ranking_preserves_publication_time(self) -> None: + self.write("site/data/history.json", {"schemaVersion": 1, "currentMonth": "2026-09", "lastUpdatedAt": "2026-09-01T00:00:00Z", "snapshots": []}) + publish_current(self.root, datetime(2026, 9, 13, 10, 30, tzinfo=timezone.utc)) + + self.assertFalse(publish_current(self.root, datetime(2026, 9, 13, 11, 30, tzinfo=timezone.utc))) + + manifest = json.loads((self.root / "site/data/history.json").read_text(encoding="utf-8")) + self.assertEqual("2026-09-13T10:30:00Z", manifest["lastUpdatedAt"]) + + def testRDA007_IntegrationPositive_changed_writers_explicitly_dispatch_pages(self) -> None: + ingest_workflow = (ROOT / ".github/workflows/ingest.yml").read_text(encoding="utf-8") + catalog_workflow = (ROOT / ".github/workflows/sync-catalog.yml").read_text(encoding="utf-8") + pages_workflow = (ROOT / ".github/workflows/pages.yml").read_text(encoding="utf-8") + + for workflow in (ingest_workflow, catalog_workflow): + self.assertIn("actions: write", workflow) + self.assertIn("gh workflow run pages.yml --ref main", workflow) + self.assertIn("site_changed", workflow) + self.assertIn("cron: '41 * * * *'", pages_workflow) + self.assertIn("git diff --quiet", pages_workflow) + self.assertIn("deploy=false", pages_workflow) + + def testRDA007_IntegrationNegative_snapshot_alone_preserves_publication_time(self) -> None: + aggregate(self.root) + self.write("site/data/history.json", {"schemaVersion": 1, "currentMonth": "2026-08", "lastUpdatedAt": "2026-08-20T12:00:00Z", "snapshots": []}) + + rollover(self.root, datetime(2026, 9, 1, tzinfo=timezone.utc)) + + manifest = json.loads((self.root / "site/data/history.json").read_text(encoding="utf-8")) + self.assertEqual("2026-08-20T12:00:00Z", manifest["lastUpdatedAt"]) + + def testRDA007_IntegrationPositive_detects_ranking_regenerated_outside_publication(self) -> None: + self.write("site/data/history.json", {"schemaVersion": 1, "currentMonth": "2026-09", "currentDataHash": None, "lastUpdatedAt": "2026-09-01T00:00:00Z", "snapshots": []}) + publish_current(self.root, datetime(2026, 9, 1, tzinfo=timezone.utc)) + catalog = json.loads((self.root / "catalog.json").read_text(encoding="utf-8")) + catalog["bots"].append({"name": "Delta", "version": "1.0", "platform": "Python", "owner": "delta-owner", "status": "active"}) + self.write("catalog.json", catalog) + aggregate(self.root) + + self.assertTrue(publish_current(self.root, datetime(2026, 9, 13, 12, tzinfo=timezone.utc))) + + manifest = json.loads((self.root / "site/data/history.json").read_text(encoding="utf-8")) + self.assertEqual("2026-09-13T12:00:00Z", manifest["lastUpdatedAt"]) + + def testRDA008_IntegrationPositive_rollover_copies_each_missing_month_byte_for_byte(self) -> None: + aggregate(self.root) + self.write("site/data/history.json", {"schemaVersion": 1, "currentMonth": "2026-08", "lastUpdatedAt": "2026-08-20T12:00:00Z", "snapshots": []}) + source = (self.root / "site/data/leaderboard/1v1.json").read_bytes() + + self.assertTrue(rollover(self.root, datetime(2026, 10, 1, tzinfo=timezone.utc))) + + self.assertEqual(source, (self.root / "site/data/snapshots/2026-08/leaderboard/1v1.json").read_bytes()) + self.assertEqual(source, (self.root / "site/data/snapshots/2026-09/leaderboard/1v1.json").read_bytes()) + manifest = json.loads((self.root / "site/data/history.json").read_text(encoding="utf-8")) + self.assertEqual(["2026-08", "2026-09"], [item["month"] for item in manifest["snapshots"]]) + self.assertEqual("2026-08-20T12:00:00Z", manifest["lastUpdatedAt"]) + + def testRDA008_IntegrationPositive_first_rollover_initializes_without_backfill(self) -> None: + self.write("site/data/history.json", {"schemaVersion": 1, "currentMonth": None, "lastUpdatedAt": "2026-09-01T00:00:00Z", "snapshots": []}) + + self.assertTrue(rollover(self.root, datetime(2026, 9, 13, tzinfo=timezone.utc))) + + manifest = json.loads((self.root / "site/data/history.json").read_text(encoding="utf-8")) + self.assertEqual("2026-09", manifest["currentMonth"]) + self.assertEqual([], manifest["snapshots"]) + + def testRDA008_IntegrationPositive_rollover_recovers_an_identical_partial_copy(self) -> None: + aggregate(self.root) + self.write("site/data/history.json", {"schemaVersion": 1, "currentMonth": "2026-08", "lastUpdatedAt": "2026-08-20T12:00:00Z", "snapshots": []}) + source = self.root / "site/data/leaderboard/1v1.json" + partial = self.root / "site/data/snapshots/2026-08/leaderboard/1v1.json" + partial.parent.mkdir(parents=True) + partial.write_bytes(source.read_bytes()) + + self.assertTrue(rollover(self.root, datetime(2026, 9, 1, tzinfo=timezone.utc))) + + self.assertTrue((self.root / "site/data/snapshots/2026-08/bots/Alpha-1.0.json").is_file()) + + def testRDA008_IntegrationNegative_rollover_refuses_to_overwrite_a_snapshot(self) -> None: + aggregate(self.root) + self.write("site/data/history.json", {"schemaVersion": 1, "currentMonth": "2026-08", "lastUpdatedAt": "2026-08-20T12:00:00Z", "snapshots": []}) + self.write("site/data/snapshots/2026-08/leaderboard/1v1.json", {"different": True}) + + with self.assertRaisesRegex(ValueError, "immutable"): + rollover(self.root, datetime(2026, 9, 1, tzinfo=timezone.utc)) + + def testUnitPositive_snapshot_check_accepts_additive_manifest_entries(self) -> None: + august = {"month": "2026-08", "updatedAt": "2026-08-20T12:00:00Z", "path": "data/snapshots/2026-08"} + september = {"month": "2026-09", "updatedAt": "2026-09-20T12:00:00Z", "path": "data/snapshots/2026-09"} + + self.assertEqual([], changed_manifest_entries(None, [august])) + self.assertEqual([], changed_manifest_entries([august], [august, september])) + + def testUnitNegative_snapshot_check_rejects_edited_or_removed_manifest_entries(self) -> None: + august = {"month": "2026-08", "updatedAt": "2026-08-20T12:00:00Z", "path": "data/snapshots/2026-08"} + september = {"month": "2026-09", "updatedAt": "2026-09-20T12:00:00Z", "path": "data/snapshots/2026-09"} + edited = {**august, "updatedAt": "2026-08-21T12:00:00Z"} + + self.assertEqual(["site/data/history.json snapshot entry 2026-08"], changed_manifest_entries([august, september], [september])) + self.assertEqual(["site/data/history.json snapshot entry 2026-08"], changed_manifest_entries([august], [edited])) + self.assertEqual(["site/data/history.json snapshot entry 2026-08"], changed_manifest_entries([august], None)) + + def testRDA009_E2EPositive_dashboard_selects_current_or_archived_data(self) -> None: + page = (ROOT / "site/index.html").read_text(encoding="utf-8") + script = (ROOT / "site/app.js").read_text(encoding="utf-8") + + self.assertIn('id="ranking-period"', page) + self.assertIn("data/history.json", script) + self.assertIn("snapshot.path", script) + self.assertIn("ranking data last updated", script) + self.assertIn("if (request !== latestLeaderboardRequest) return;", script) + + def testRDA009_E2ENegative_dashboard_marks_archived_rankings_read_only(self) -> None: + page = (ROOT / "site/index.html").read_text(encoding="utf-8") + script = (ROOT / "site/app.js").read_text(encoding="utf-8") + + self.assertIn("read-only month-end snapshot", page) + self.assertIn("archiveNotice.hidden = !snapshot", script) + + def testRBC005_IntegrationPositive_changed_catalog_is_written_for_publication(self) -> None: + source = {"schemaVersion": 1, "generatedAt": "2026-09-13T12:00:00Z", "commit": "new", "bots": [{"name": "Alpha", "version": "2.0", "status": "active"}]} + self.write("catalog.json", {"schemaVersion": 1, "source": "https://example.test/bots/index.json", "sourceCommit": "old", "sourceGeneratedAt": "2026-09-01T00:00:00Z", "bots": []}) + + self.assertTrue(sync(self.root, lambda _: json.dumps(source).encode("utf-8"))) + + self.assertEqual("new", json.loads((self.root / "catalog.json").read_text(encoding="utf-8"))["sourceCommit"]) + + def testRBC005_IntegrationNegative_unchanged_catalog_skips_rewrite_and_aggregation(self) -> None: + source = {"schemaVersion": 1, "generatedAt": "2026-09-13T12:00:00Z", "commit": "same", "bots": [{"name": "Alpha", "version": "1.0", "status": "active", "teamMembers": []}]} + self.write("catalog.json", {"schemaVersion": 1, "source": "https://example.test/bots/index.json", "sourceCommit": "same", "sourceGeneratedAt": "2026-09-13T12:00:00Z", "bots": source["bots"]}) + + self.assertFalse(sync(self.root, lambda _: json.dumps(source).encode("utf-8"))) + + workflow = (ROOT / ".github/workflows/sync-catalog.yml").read_text(encoding="utf-8") + self.assertIn("skipping aggregation", workflow) + self.assertIn("rumble-publication-writer", workflow) + if __name__ == "__main__": unittest.main()