feat(scanner): add page-scale ribbon candidate #188
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| pull_request: | |
| push: | |
| branches: [main] | |
| jobs: | |
| test: | |
| name: ${{ matrix.os }} / ${{ matrix.toolchain }} | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, windows-latest, macos-latest] | |
| toolchain: [stable] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| components: rustfmt, clippy | |
| - uses: Swatinem/rust-cache@v2 | |
| - run: cargo fmt --all --check | |
| - run: cargo clippy --workspace --all-targets --all-features -- -D warnings | |
| - run: cargo test --workspace --all-features | |
| - run: cargo doc --workspace --all-features --no-deps | |
| - name: Enforce docs on phase-critical public APIs | |
| env: | |
| RUSTDOCFLAGS: -Dmissing-docs | |
| run: | | |
| cargo doc -p glyphnet-ecc --no-deps | |
| cargo doc -p glyphnet-decode --no-deps | |
| cargo doc -p glyphnet-scanner --no-deps | |
| scanner-perf-gate: | |
| name: scanner perf gate | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| env: | |
| SCANNER_BENCH_TOLERANCE_PCT: "200" | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Check PR branch perf gate | |
| if: github.event_name == 'pull_request' | |
| continue-on-error: true | |
| run: SCANNER_BENCH_NAMES=scan_generated_ribbon_canvas_small,scan_generated_ribbon_canvas_medium,scan_generated_ribbon_canvas_large,scan_generated_matrix_roi,scan_generated_matrix_canvas,scan_real_debugger_screenshot SCANNER_BENCH_NON_GATING=scan_generated_matrix_canvas,scan_real_debugger_screenshot SCANNER_BENCH_OUTPUT_JSON=target/scanner-perf/pr.json scripts/check_scanner_perf.sh | |
| - name: Capture base benchmark metrics | |
| if: always() && github.event_name == 'pull_request' | |
| continue-on-error: true | |
| run: | | |
| git worktree add /tmp/glyphnet-base origin/${{ github.base_ref }} | |
| mkdir -p /tmp/glyphnet-base/scripts | |
| cp scripts/check_scanner_perf.sh /tmp/glyphnet-base/scripts/check_scanner_perf.sh | |
| cd /tmp/glyphnet-base | |
| SCANNER_BENCH_NAMES=scan_generated_ribbon_canvas_small,scan_generated_ribbon_canvas_medium,scan_generated_ribbon_canvas_large,scan_generated_matrix_roi,scan_generated_matrix_canvas,scan_real_debugger_screenshot SCANNER_BENCH_NON_GATING=scan_generated_matrix_canvas,scan_real_debugger_screenshot SCANNER_BENCH_OUTPUT_JSON=target/scanner-perf/base.json ./scripts/check_scanner_perf.sh --no-fail | |
| - name: Comment benchmark comparison on PR | |
| if: always() && github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const prPath = 'target/scanner-perf/pr.json'; | |
| const basePath = '/tmp/glyphnet-base/target/scanner-perf/base.json'; | |
| const pr = fs.existsSync(prPath) | |
| ? JSON.parse(fs.readFileSync(prPath, 'utf8')) | |
| : null; | |
| const base = fs.existsSync(basePath) | |
| ? JSON.parse(fs.readFileSync(basePath, 'utf8')) | |
| : null; | |
| const toMap = payload => new Map((payload?.cases || []).map(c => [c.bench_name, c])); | |
| const prMap = toMap(pr); | |
| const baseMap = toMap(base); | |
| const benchNames = Array.from(new Set([...prMap.keys(), ...baseMap.keys()])); | |
| const matrixBenchNames = benchNames.filter(name => name.includes('matrix')); | |
| const matrixCases = matrixBenchNames | |
| .map(name => prMap.get(name)) | |
| .filter(Boolean); | |
| const matrixGatingCases = matrixCases.filter(c => c.gating !== false); | |
| const matrixPassCount = matrixGatingCases.filter(c => c.status === 'pass').length; | |
| const matrixFailCount = matrixGatingCases.filter(c => c.status === 'fail').length; | |
| const ribbonBenchNames = benchNames.filter(name => !name.includes('matrix')); | |
| const ribbonCases = ribbonBenchNames | |
| .map(name => prMap.get(name)) | |
| .filter(Boolean); | |
| const ribbonGatingCases = ribbonCases.filter(c => c.gating !== false); | |
| const ribbonPassCount = ribbonGatingCases.filter(c => c.status === 'pass').length; | |
| const ribbonFailCount = ribbonGatingCases.filter(c => c.status === 'fail').length; | |
| const overallEmoji = pr ? (pr.status === 'pass' ? '✅' : '⚠️') : 'ℹ️'; | |
| const marker = '<!-- scanner-perf-comment -->'; | |
| const rows = benchNames.length === 0 | |
| ? ['| Benchmark | Gating | PR median (ms) | Base median (ms) | Delta (ms) | Delta % |', | |
| '|---|:---:|---:|---:|---:|---:|', | |
| '| unavailable | - | - | - | - | - |'] | |
| : [ | |
| '| Benchmark | Gating | PR median (ms) | Base median (ms) | Delta (ms) | Delta % |', | |
| '|---|:---:|---:|---:|---:|---:|', | |
| ...benchNames.map(name => { | |
| const p = prMap.get(name); | |
| const b = baseMap.get(name); | |
| const prMs = p ? p.median_ms : null; | |
| const baseMs = b ? b.median_ms : null; | |
| const delta = (prMs != null && baseMs != null) ? (prMs - baseMs) : null; | |
| const deltaPct = (delta != null && baseMs !== 0) ? (delta / baseMs) * 100 : null; | |
| const prCell = prMs == null ? '-' : prMs.toFixed(3); | |
| const baseCell = baseMs == null ? '-' : baseMs.toFixed(3); | |
| const deltaCell = delta == null ? '-' : delta.toFixed(3); | |
| const deltaPctCell = deltaPct == null ? '-' : `${deltaPct.toFixed(2)}%`; | |
| const gateCell = p && p.gating === false ? 'No' : 'Yes'; | |
| const profile = p?.profile_id || b?.profile_id || (name.includes('matrix') ? 'MatrixCompat' : 'RibbonPrint'); | |
| return `| ${name} (${profile}) | ${gateCell} | ${prCell} | ${baseCell} | ${deltaCell} | ${deltaPctCell} |`; | |
| }) | |
| ]; | |
| const matrixRows = matrixBenchNames.length === 0 | |
| ? [ | |
| '| Benchmark | Gating | PR median (ms) | Base median (ms) | Delta (ms) | Delta % |', | |
| '|---|:---:|---:|---:|---:|---:|', | |
| '| unavailable | - | - | - | - | - |', | |
| ] | |
| : [ | |
| '| Benchmark | Gating | PR median (ms) | Base median (ms) | Delta (ms) | Delta % |', | |
| '|---|:---:|---:|---:|---:|---:|', | |
| ...matrixBenchNames.map(name => { | |
| const p = prMap.get(name); | |
| const b = baseMap.get(name); | |
| const prMs = p ? p.median_ms : null; | |
| const baseMs = b ? b.median_ms : null; | |
| const delta = (prMs != null && baseMs != null) ? (prMs - baseMs) : null; | |
| const deltaPct = (delta != null && baseMs !== 0) ? (delta / baseMs) * 100 : null; | |
| const prCell = prMs == null ? '-' : prMs.toFixed(3); | |
| const baseCell = baseMs == null ? '-' : baseMs.toFixed(3); | |
| const deltaCell = delta == null ? '-' : delta.toFixed(3); | |
| const deltaPctCell = deltaPct == null ? '-' : `${deltaPct.toFixed(2)}%`; | |
| const gateCell = p && p.gating === false ? 'No' : 'Yes'; | |
| const profile = p?.profile_id || b?.profile_id || 'MatrixCompat'; | |
| return `| ${name} (${profile}) | ${gateCell} | ${prCell} | ${baseCell} | ${deltaCell} | ${deltaPctCell} |`; | |
| }), | |
| ]; | |
| const body = [ | |
| marker, | |
| '## Scanner perf (ribbon + matrix fixtures)', | |
| '', | |
| pr | |
| ? `${overallEmoji} **PR gate status:** ${pr.status.toUpperCase()}` | |
| : '⚠️ **PR gate status:** unavailable (PR benchmark did not produce target/scanner-perf/pr.json)', | |
| pr | |
| ? `- **Ribbon budget:** ${pr.ribbon_budget_ms.toFixed(3)} ms (allowed ${pr.ribbon_allowed_ms.toFixed(3)} ms)` | |
| : '- **Ribbon budget:** unavailable', | |
| pr | |
| ? `- **Matrix budget:** ${pr.matrix_budget_ms.toFixed(3)} ms (allowed ${pr.matrix_allowed_ms.toFixed(3)} ms)` | |
| : '- **Matrix budget:** unavailable', | |
| pr | |
| ? `- **Tolerance:** ${pr.tolerance_pct.toFixed(1)}%` | |
| : '- **Tolerance:** unavailable', | |
| ribbonBenchNames.length > 0 | |
| ? `- **Ribbon gating benches:** ${ribbonPassCount} pass / ${ribbonFailCount} fail` | |
| : '- **Ribbon gating benches:** unavailable', | |
| matrixBenchNames.length > 0 | |
| ? `- **Matrix gating benches:** ${matrixPassCount} pass / ${matrixFailCount} fail` | |
| : '- **Matrix gating benches:** unavailable', | |
| '', | |
| rows[0], | |
| rows[1], | |
| ...rows.slice(2), | |
| '', | |
| '### Matrix subset', | |
| ...matrixRows, | |
| '', | |
| ].join('\n'); | |
| const { owner, repo } = context.repo; | |
| const issue_number = context.issue.number; | |
| const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); | |
| const existing = comments.find(c => c.body && c.body.includes(marker) && c.user && c.user.type === 'Bot'); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); | |
| } else { | |
| await github.rest.issues.createComment({ owner, repo, issue_number, body }); | |
| } | |
| burst-reliability: | |
| name: burst reliability | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - uses: dtolnay/rust-toolchain@stable | |
| - uses: Swatinem/rust-cache@v2 | |
| - name: Check PR branch burst reliability | |
| if: github.event_name == 'pull_request' | |
| continue-on-error: true | |
| run: BURST_RELIABILITY_OUTPUT_JSON=target/burst-reliability/pr.json bash scripts/check_burst_reliability.sh --no-fail | |
| - name: Capture base burst reliability | |
| if: always() && github.event_name == 'pull_request' | |
| continue-on-error: true | |
| run: | | |
| git worktree add /tmp/glyphnet-base origin/${{ github.base_ref }} | |
| mkdir -p /tmp/glyphnet-base/scripts | |
| cp scripts/check_burst_reliability.sh /tmp/glyphnet-base/scripts/check_burst_reliability.sh | |
| cd /tmp/glyphnet-base | |
| BURST_RELIABILITY_OUTPUT_JSON=target/burst-reliability/base.json bash ./scripts/check_burst_reliability.sh --no-fail | |
| - name: Comment burst reliability comparison on PR | |
| if: always() && github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const prPath = 'target/burst-reliability/pr.json'; | |
| const basePath = '/tmp/glyphnet-base/target/burst-reliability/base.json'; | |
| const pr = fs.existsSync(prPath) ? JSON.parse(fs.readFileSync(prPath, 'utf8')) : null; | |
| const base = fs.existsSync(basePath) ? JSON.parse(fs.readFileSync(basePath, 'utf8')) : null; | |
| const byDrop = (payload) => new Map((payload?.cases || []).map(c => [Number(c.drop_rate).toFixed(2), c])); | |
| const prMap = byDrop(pr); | |
| const baseMap = byDrop(base); | |
| const keys = Array.from(new Set([...prMap.keys(), ...baseMap.keys()])).sort(); | |
| const marker = '<!-- burst-reliability-comment -->'; | |
| const rows = keys.length === 0 | |
| ? [ | |
| '| Drop rate | Gating | PR success | Base success | Delta | PR median frames | Base median frames |', | |
| '|---:|:---:|---:|---:|---:|---:|---:|', | |
| '| - | - | - | - | - | - | - |' | |
| ] | |
| : [ | |
| '| Drop rate | Gating | PR success | Base success | Delta | PR median frames | Base median frames |', | |
| '|---:|:---:|---:|---:|---:|---:|---:|', | |
| ...keys.map(k => { | |
| const p = prMap.get(k); | |
| const b = baseMap.get(k); | |
| const ps = p ? (p.success_rate * 100) : null; | |
| const bs = b ? (b.success_rate * 100) : null; | |
| const delta = (ps != null && bs != null) ? (ps - bs) : null; | |
| const pFrames = p?.median_frames ?? '-'; | |
| const bFrames = b?.median_frames ?? '-'; | |
| const gating = p ? (p.gating !== false ? 'Yes' : 'No') : (b && b.gating !== false ? 'Yes' : 'No'); | |
| return `| ${(Number(k) * 100).toFixed(0)}% | ${gating} | ${ps == null ? '-' : ps.toFixed(1) + '%'} | ${bs == null ? '-' : bs.toFixed(1) + '%'} | ${delta == null ? '-' : delta.toFixed(1) + 'pp'} | ${pFrames} | ${bFrames} |`; | |
| }) | |
| ]; | |
| const gatingCases = (pr?.cases || []).filter(c => c.gating !== false); | |
| const passCount = gatingCases.filter(c => c.status === 'pass').length; | |
| const failCount = gatingCases.filter(c => c.status === 'fail').length; | |
| const body = [ | |
| marker, | |
| '## Burst reliability (loss sweep)', | |
| '', | |
| pr | |
| ? `${pr.status === 'pass' ? '✅' : '⚠️'} **PR gate status:** ${pr.status.toUpperCase()}` | |
| : '⚠️ **PR status:** unavailable', | |
| pr | |
| ? `- **Gating rows:** ${passCount} pass / ${failCount} fail` | |
| : '- **Gating rows:** unavailable', | |
| '- **Non-gating rows:** none', | |
| '', | |
| ...rows, | |
| '', | |
| ].join('\n'); | |
| const { owner, repo } = context.repo; | |
| const issue_number = context.issue.number; | |
| const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); | |
| const existing = comments.find(c => c.body && c.body.includes(marker) && c.user && c.user.type === 'Bot'); | |
| if (existing) { | |
| await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); | |
| } else { | |
| await github.rest.issues.createComment({ owner, repo, issue_number, body }); | |
| } | |
| wasm: | |
| name: wasm32 check | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: wasm32-unknown-unknown | |
| - uses: Swatinem/rust-cache@v2 | |
| - run: cargo check -p glyphnet-wasm --target wasm32-unknown-unknown | |
| browser-sdk: | |
| name: browser sdk | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: dtolnay/rust-toolchain@stable | |
| with: | |
| targets: wasm32-unknown-unknown | |
| - uses: Swatinem/rust-cache@v2 | |
| - uses: oven-sh/setup-bun@v2 | |
| - uses: jetli/wasm-pack-action@v0.4.0 | |
| - name: Install browser SDK deps | |
| working-directory: sdk/browser | |
| run: bun install --frozen-lockfile | |
| - name: Build browser SDK package | |
| working-directory: sdk/browser | |
| run: bun run build | |
| - name: Typecheck browser SDK | |
| working-directory: sdk/browser | |
| run: bun run typecheck | |
| - name: Dry-run npm pack | |
| working-directory: sdk/browser | |
| run: bun run pack:dry-run | |
| - name: Consumer smoke test (install + TypeScript import) | |
| run: | | |
| set -euo pipefail | |
| cd sdk/browser | |
| PACKAGE_TGZ="$(npm pack --ignore-scripts | tail -n1)" | |
| CONSUMER_DIR="/tmp/glyphnet-browser-consumer" | |
| rm -rf "$CONSUMER_DIR" | |
| mkdir -p "$CONSUMER_DIR" | |
| cat > "$CONSUMER_DIR/package.json" <<'JSON' | |
| { | |
| "name": "glyphnet-browser-consumer-smoke", | |
| "private": true, | |
| "type": "module" | |
| } | |
| JSON | |
| cd "$CONSUMER_DIR" | |
| bun add "$GITHUB_WORKSPACE/sdk/browser/$PACKAGE_TGZ" | |
| bun add -d typescript | |
| cat > ./smoke.ts <<'TS' | |
| import { initGlyphNet, GlyphNetBrowser } from "@glyphnet/browser"; | |
| void initGlyphNet; | |
| void GlyphNetBrowser; | |
| TS | |
| bunx tsc --module es2022 --moduleResolution bundler --target es2022 --noEmit smoke.ts |