Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>CounterFeint — Multi-Agent Ad Fraud Arena</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" /> | |
| <style> | |
| *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } | |
| :root { | |
| --bg: #0a0d14; | |
| --surface: rgba(255,255,255,0.035); | |
| --surface-hover: rgba(255,255,255,0.07); | |
| --border: rgba(255,255,255,0.07); | |
| --text: #e2e8f0; | |
| --text-dim: #8b95a5; | |
| --text-muted: #5a6377; | |
| --indigo: #6366f1; | |
| --indigo-glow: rgba(99,102,241,0.25); | |
| --red: #ef4444; | |
| --red-glow: rgba(239,68,68,0.2); | |
| --green: #22c55e; | |
| --green-glow: rgba(34,197,94,0.2); | |
| --amber: #f59e0b; | |
| --amber-glow: rgba(245,158,11,0.2); | |
| --cyan: #06b6d4; | |
| --cyan-glow: rgba(6,182,212,0.15); | |
| --radius: 14px; | |
| --radius-sm: 8px; | |
| } | |
| body { | |
| font-family: 'Inter', -apple-system, sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| min-height: 100vh; | |
| overflow-x: hidden; | |
| } | |
| body::before { | |
| content: ''; | |
| position: fixed; | |
| width: 500px; height: 500px; | |
| background: radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%); | |
| top: -180px; left: -80px; | |
| border-radius: 50%; | |
| pointer-events: none; | |
| z-index: 0; | |
| } | |
| .container { max-width: 1360px; margin: 0 auto; padding: 20px 24px; position: relative; z-index: 1; } | |
| /* ── Header ── */ | |
| header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; flex-wrap: wrap; gap: 16px; } | |
| .logo { display: flex; align-items: center; gap: 14px; } | |
| .logo-icon { | |
| width: 44px; height: 44px; | |
| background: linear-gradient(135deg, var(--indigo), var(--cyan)); | |
| border-radius: 12px; | |
| display: grid; place-items: center; | |
| font-size: 20px; | |
| box-shadow: 0 4px 20px var(--indigo-glow); | |
| } | |
| .logo h1 { font-size: 1.2rem; font-weight: 800; letter-spacing: -0.3px; } | |
| .logo span { display: block; font-size: 0.68rem; color: var(--text-dim); margin-top: 1px; } | |
| .header-badges { display: flex; gap: 8px; } | |
| .badge { | |
| padding: 5px 12px; border-radius: 999px; font-size: 0.62rem; | |
| font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase; | |
| } | |
| .badge-accent { background: var(--indigo-glow); color: #a5b4fc; border: 1px solid rgba(99,102,241,0.25); } | |
| .badge-green { background: var(--green-glow); color: #86efac; border: 1px solid rgba(34,197,94,0.25); display: flex; align-items: center; gap: 5px; } | |
| .pulse { width: 6px; height: 6px; background: var(--green); border-radius: 50%; animation: pulse 2s ease-in-out infinite; } | |
| @keyframes pulse { 0%,100%{box-shadow:0 0 0 0 rgba(34,197,94,0.3)} 50%{box-shadow:0 0 0 6px transparent} } | |
| /* ── Tabs ── */ | |
| .tab-bar { | |
| display: flex; gap: 6px; margin-bottom: 24px; | |
| background: var(--surface); border-radius: var(--radius-sm); | |
| padding: 5px 6px; width: fit-content; | |
| } | |
| .tab-btn { | |
| padding: 9px 22px; border: none; border-radius: 6px; | |
| font-family: inherit; font-size: 0.78rem; font-weight: 600; | |
| color: var(--text-dim); background: transparent; cursor: pointer; | |
| transition: all 0.2s; | |
| position: relative; | |
| } | |
| .tab-btn:hover { color: var(--text); } | |
| .tab-btn.active { background: var(--indigo); color: #fff; box-shadow: 0 2px 12px var(--indigo-glow); } | |
| /* Tab tooltips */ | |
| .tab-btn[data-tooltip]::after { | |
| content: attr(data-tooltip); | |
| position: absolute; | |
| bottom: calc(100% + 10px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| padding: 10px 16px; | |
| background: rgba(15,23,42,0.96); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| border-radius: 10px; | |
| font-size: 0.68rem; | |
| font-weight: 400; | |
| color: var(--text-dim); | |
| white-space: normal; | |
| width: 240px; | |
| text-align: center; | |
| line-height: 1.5; | |
| pointer-events: none; | |
| opacity: 0; | |
| transition: opacity 0.2s ease; | |
| z-index: 100; | |
| box-shadow: 0 8px 24px rgba(0,0,0,0.4); | |
| } | |
| .tab-btn[data-tooltip]:hover::after { opacity: 1; } | |
| .tab-content { display: none; } | |
| .tab-content.active { display: block; } | |
| /* ── Shared components ── */ | |
| .panel { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: var(--radius); overflow: hidden; margin-bottom: 16px; | |
| } | |
| .panel-hdr { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 14px 18px; border-bottom: 1px solid var(--border); | |
| } | |
| .panel-title { font-size: 0.82rem; font-weight: 700; } | |
| .panel-body { padding: 16px 18px; } | |
| .btn { | |
| display: inline-flex; align-items: center; gap: 7px; | |
| padding: 10px 20px; border: none; border-radius: var(--radius-sm); | |
| font-family: inherit; font-size: 0.78rem; font-weight: 600; | |
| cursor: pointer; transition: all 0.2s; | |
| } | |
| .btn:disabled { opacity: 0.4; cursor: not-allowed; } | |
| .btn-primary { background: linear-gradient(135deg, var(--indigo), #818cf8); color: #fff; box-shadow: 0 3px 14px var(--indigo-glow); } | |
| .btn-red { background: linear-gradient(135deg, #dc2626, var(--red)); color: #fff; box-shadow: 0 3px 14px var(--red-glow); } | |
| .btn-green { background: linear-gradient(135deg, #059669, var(--green)); color: #fff; box-shadow: 0 3px 14px var(--green-glow); } | |
| .btn-amber { background: linear-gradient(135deg, #d97706, var(--amber)); color: #fff; box-shadow: 0 3px 14px var(--amber-glow); } | |
| .btn-ghost { background: var(--surface); color: var(--text); border: 1px solid var(--border); } | |
| .stat-card { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: var(--radius); padding: 14px 16px; | |
| } | |
| .stat-label { font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-dim); margin-bottom: 4px; } | |
| .stat-value { font-size: 1.5rem; font-weight: 800; } | |
| /* Guidance banners */ | |
| .guide-banner { | |
| margin-bottom: 18px; padding: 14px 20px; border-radius: var(--radius); | |
| font-size: 0.78rem; line-height: 1.6; color: var(--text-dim); | |
| } | |
| .guide-banner strong { color: var(--text); } | |
| .guide-banner code { color: var(--cyan); font-size: 0.74rem; } | |
| .guide-banner.arena { background: linear-gradient(135deg,rgba(239,68,68,0.04),rgba(99,102,241,0.05)); border: 1px solid rgba(99,102,241,0.15); } | |
| .guide-banner.playground { background: linear-gradient(135deg,rgba(99,102,241,0.06),rgba(6,182,212,0.04)); border: 1px solid rgba(99,102,241,0.2); } | |
| .guide-banner.results { background: linear-gradient(135deg,rgba(34,197,94,0.06),rgba(6,182,212,0.04)); border: 1px solid rgba(34,197,94,0.2); } | |
| /* ── Toast ── */ | |
| .toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 1000; display: flex; flex-direction: column; gap: 6px; } | |
| .toast { | |
| padding: 12px 18px; border-radius: var(--radius-sm); font-size: 0.78rem; | |
| max-width: 340px; animation: slideIn 0.3s ease; | |
| } | |
| .toast.success { background: rgba(34,197,94,0.15); border: 1px solid rgba(34,197,94,0.3); color: #86efac; } | |
| .toast.error { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5; } | |
| .toast.info { background: rgba(6,182,212,0.12); border: 1px solid rgba(6,182,212,0.3); color: #67e8f9; } | |
| @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } | |
| /* ════════════════════════ ARENA TAB ════════════════════════ */ | |
| .agent-icon { | |
| width: 48px; height: 48px; border-radius: 14px; | |
| display: grid; place-items: center; font-size: 22px; | |
| flex-shrink: 0; | |
| } | |
| .agent-icon.fraudster { background: linear-gradient(135deg, rgba(239,68,68,0.2), rgba(239,68,68,0.08)); border: 1px solid rgba(239,68,68,0.3); } | |
| .agent-icon.investigator { background: linear-gradient(135deg, rgba(99,102,241,0.2), rgba(99,102,241,0.08)); border: 1px solid rgba(99,102,241,0.3); } | |
| .agent-icon.auditor { background: linear-gradient(135deg, rgba(245,158,11,0.2), rgba(245,158,11,0.08)); border: 1px solid rgba(245,158,11,0.3); } | |
| .agent-label { font-size: 0.62rem; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; } | |
| .agent-label.fraudster { color: var(--red); } | |
| .agent-label.investigator { color: var(--indigo); } | |
| .agent-label.auditor { color: var(--amber); } | |
| .phase-bar { | |
| display: flex; align-items: center; gap: 0; margin-bottom: 20px; | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: var(--radius); padding: 10px 16px; overflow-x: auto; | |
| } | |
| .phase-step { | |
| display: flex; align-items: center; gap: 8px; | |
| padding: 6px 14px; border-radius: 6px; font-size: 0.72rem; | |
| font-weight: 600; color: var(--text-muted); white-space: nowrap; | |
| transition: all 0.3s; | |
| } | |
| .phase-step.active { color: #fff; } | |
| .phase-step.active.f { background: var(--red-glow); color: var(--red); } | |
| .phase-step.active.i { background: var(--indigo-glow); color: #a5b4fc; } | |
| .phase-step.active.a { background: var(--amber-glow); color: var(--amber); } | |
| .phase-step.active.d { background: var(--green-glow); color: var(--green); } | |
| .phase-step.done-phase { color: var(--text-dim); } | |
| .phase-arrow { color: var(--text-muted); font-size: 0.7rem; margin: 0 4px; } | |
| .arena-controls { | |
| display: flex; gap: 10px; margin-bottom: 18px; flex-wrap: wrap; align-items: center; | |
| } | |
| .arena-controls select { | |
| padding: 10px 14px; border-radius: var(--radius-sm); font-size: 0.78rem; | |
| font-family: inherit; background: #0f172a; color: #f1f5f9; | |
| border: 1px solid rgba(148,163,184,0.35); | |
| } | |
| .arena-controls select option { background: #0f172a; color: #f1f5f9; } | |
| .arena-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 18px; } | |
| .arena-main { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; margin-bottom: 16px; } | |
| @media (max-width: 1024px) { .arena-main { grid-template-columns: 1fr; } } | |
| .agent-hdr { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--border); } | |
| .agent-hdr-info { flex: 1; } | |
| .agent-name { font-size: 0.85rem; font-weight: 700; } | |
| .agent-desc { font-size: 0.65rem; color: var(--text-dim); margin-top: 2px; } | |
| .reward-badge { | |
| padding: 4px 10px; border-radius: 999px; font-size: 0.68rem; | |
| font-weight: 700; font-variant-numeric: tabular-nums; | |
| } | |
| .reward-badge.pos { background: var(--green-glow); color: var(--green); } | |
| .reward-badge.neg { background: var(--red-glow); color: var(--red); } | |
| .reward-badge.zero { background: var(--surface); color: var(--text-dim); border: 1px solid var(--border); } | |
| .trace-timeline { | |
| max-height: 320px; overflow-y: auto; padding: 12px 16px; | |
| font-size: 0.72rem; | |
| } | |
| .trace-entry { | |
| display: flex; align-items: flex-start; gap: 10px; | |
| padding: 8px 0; border-bottom: 1px solid var(--border); | |
| animation: fadeIn 0.3s ease; | |
| } | |
| .trace-entry:last-child { border-bottom: none; } | |
| @keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } | |
| .trace-dot { | |
| width: 8px; height: 8px; border-radius: 50%; margin-top: 4px; flex-shrink: 0; | |
| } | |
| .trace-dot.fraudster { background: var(--red); } | |
| .trace-dot.investigator { background: var(--indigo); } | |
| .trace-dot.auditor { background: var(--amber); } | |
| .trace-text { flex: 1; color: var(--text-dim); line-height: 1.4; } | |
| .trace-text strong { color: var(--text); } | |
| .trace-reward { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; } | |
| .trace-reward.pos { color: var(--green); } | |
| .trace-reward.neg { color: var(--red); } | |
| .chart-container { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: var(--radius); padding: 16px 20px; margin-bottom: 16px; | |
| } | |
| .chart-title { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-dim); margin-bottom: 10px; } | |
| .chart-legend { display: flex; gap: 18px; margin-top: 10px; } | |
| .chart-legend-item { display: flex; align-items: center; gap: 6px; font-size: 0.65rem; color: var(--text-dim); } | |
| .chart-legend-dot { width: 8px; height: 8px; border-radius: 2px; } | |
| .queue-grid { display: flex; flex-wrap: wrap; gap: 6px; } | |
| .q-chip { | |
| padding: 6px 12px; border-radius: 6px; font-size: 0.7rem; font-weight: 600; | |
| border: 1px solid var(--border); color: var(--text-dim); | |
| display: inline-flex; align-items: center; gap: 5px; | |
| transition: all 0.2s; | |
| } | |
| .q-chip.pending { border-color: var(--text-muted); } | |
| .q-chip.approved { border-color: var(--green); color: var(--green); } | |
| .q-chip.rejected { border-color: var(--red); color: var(--red); } | |
| .q-chip.escalated { border-color: var(--cyan); color: var(--cyan); } | |
| .q-chip.proposed { border-color: var(--amber); color: var(--amber); } | |
| .q-dot { width: 6px; height: 6px; border-radius: 50%; } | |
| .audit-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } | |
| @media (max-width: 768px) { .audit-grid { grid-template-columns: 1fr; } } | |
| .flag-entry { | |
| padding: 8px 12px; border-radius: 6px; | |
| border: 1px solid var(--border); font-size: 0.72rem; | |
| background: rgba(0,0,0,0.15); | |
| } | |
| .flag-entry .flag-type { font-weight: 700; font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.5px; } | |
| .flag-entry.track-a .flag-type { color: var(--indigo); } | |
| .flag-entry.track-b .flag-type { color: var(--amber); } | |
| .severity-bar { | |
| height: 3px; border-radius: 2px; margin-top: 4px; | |
| background: rgba(255,255,255,0.06); | |
| } | |
| .severity-fill { height: 100%; border-radius: 2px; } | |
| .severity-fill.low { background: var(--green); } | |
| .severity-fill.med { background: var(--amber); } | |
| .severity-fill.high { background: var(--red); } | |
| /* Arena agent action scrollable area */ | |
| .agent-actions-scroll { | |
| max-height: 400px; overflow-y: auto; scroll-behavior: smooth; | |
| scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.15) transparent; | |
| } | |
| .agent-actions-scroll::-webkit-scrollbar { width: 5px; } | |
| .agent-actions-scroll::-webkit-scrollbar-track { background: transparent; } | |
| .agent-actions-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } | |
| /* ════════════════════════ PLAYGROUND TAB ════════════════════════ */ | |
| .pg-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin-bottom: 16px; } | |
| .pg-grid { display: grid; grid-template-columns: 1fr 360px; gap: 16px; } | |
| @media (max-width: 1024px) { .pg-grid { grid-template-columns: 1fr; } } | |
| .inv-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } | |
| @media (max-width: 768px) { .inv-grid { grid-template-columns: repeat(2, 1fr); } } | |
| .inv-card { | |
| border: 1px solid var(--border); border-radius: var(--radius-sm); | |
| padding: 10px 12px; min-height: 76px; position: relative; | |
| overflow: hidden; background: rgba(0,0,0,0.12); | |
| } | |
| .inv-card.revealed { border-color: rgba(99,102,241,0.4); } | |
| .inv-card.locked .inv-inner { filter: blur(4px); opacity: 0.15; } | |
| .inv-card.locked::after { | |
| content: ''; position: absolute; inset: 0; | |
| background: repeating-linear-gradient(-45deg, transparent, transparent 5px, rgba(255,255,255,0.03) 5px, rgba(255,255,255,0.03) 10px); | |
| pointer-events: none; | |
| } | |
| .inv-label { font-size: 0.58rem; text-transform: uppercase; letter-spacing: 0.7px; font-weight: 700; color: var(--indigo); margin-bottom: 4px; } | |
| .inv-card.locked .inv-label { color: var(--text-muted); } | |
| .inv-content { font-size: 0.68rem; line-height: 1.35; color: var(--text-dim); } | |
| .lock-icon { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); font-size: 1.1rem; z-index: 2; } | |
| .form-group label { | |
| display: block; font-size: 0.62rem; font-weight: 700; text-transform: uppercase; | |
| letter-spacing: 0.7px; color: var(--text-dim); margin-bottom: 5px; | |
| } | |
| .form-group select, .form-group input, .form-group textarea { | |
| width: 100%; padding: 9px 12px; background: rgba(15,23,42,0.95); | |
| border: 1px solid var(--border); border-radius: var(--radius-sm); | |
| color: #f1f5f9; font-family: inherit; font-size: 0.78rem; outline: none; | |
| } | |
| .form-group select option { background: #0f172a; color: #f1f5f9; } | |
| .form-group textarea { min-height: 80px; resize: vertical; line-height: 1.4; } | |
| .form-group select:focus, .form-group input:focus, .form-group textarea:focus { | |
| border-color: var(--indigo); box-shadow: 0 0 0 2px var(--indigo-glow); | |
| } | |
| .action-form { display: flex; flex-direction: column; gap: 12px; } | |
| .verdict-row { | |
| display: flex; justify-content: space-between; align-items: center; | |
| padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; | |
| margin-bottom: 4px; font-size: 0.75rem; | |
| } | |
| .v-badge { padding: 2px 8px; border-radius: 999px; font-size: 0.58rem; font-weight: 700; text-transform: uppercase; } | |
| .v-badge.approve { background: var(--green-glow); color: var(--green); } | |
| .v-badge.reject { background: var(--red-glow); color: var(--red); } | |
| .v-badge.escalate { background: var(--indigo-glow); color: #a5b4fc; } | |
| .ad-queue { display: flex; flex-wrap: wrap; gap: 8px; } | |
| .ad-chip { | |
| padding: 7px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border); | |
| font-size: 0.75rem; font-weight: 600; color: var(--text-dim); | |
| display: inline-flex; align-items: center; gap: 6px; | |
| } | |
| .ad-chip.focus { border-color: var(--amber); color: var(--cyan); } | |
| .ad-chip.approved { border-color: var(--green); color: var(--green); } | |
| .ad-chip.rejected { border-color: var(--red); color: var(--red); } | |
| .ad-copy-block { | |
| background: rgba(0,0,0,0.2); border-left: 3px solid var(--cyan); | |
| padding: 10px 14px; border-radius: 0 var(--radius-sm) var(--radius-sm) 0; | |
| font-style: italic; color: var(--text-dim); line-height: 1.45; font-size: 0.8rem; | |
| } | |
| .hidden { display: none ; } | |
| .log-area { max-height: 180px; overflow-y: auto; font-family: ui-monospace, monospace; font-size: 0.65rem; } | |
| .log-entry { padding: 6px 8px; border-radius: 4px; margin-bottom: 3px; background: rgba(0,0,0,0.15); color: var(--text-dim); } | |
| .log-entry.ok { color: var(--green); } | |
| .log-entry.bad { color: var(--red); } | |
| /* ════════════════════════ RESULTS TAB ════════════════════════ */ | |
| .results-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } | |
| @media (max-width: 768px) { .results-grid { grid-template-columns: 1fr; } } | |
| .results-table { | |
| width: 100%; border-collapse: collapse; font-size: 0.75rem; | |
| } | |
| .results-table th { | |
| text-align: left; padding: 10px 12px; font-size: 0.62rem; | |
| font-weight: 700; text-transform: uppercase; letter-spacing: 0.7px; | |
| color: var(--text-dim); border-bottom: 1px solid var(--border); | |
| } | |
| .results-table td { | |
| padding: 10px 12px; border-bottom: 1px solid var(--border); | |
| font-variant-numeric: tabular-nums; | |
| } | |
| .results-table tr:last-child td { border-bottom: none; } | |
| .score-pill { | |
| display: inline-block; padding: 2px 8px; border-radius: 4px; | |
| font-weight: 700; font-size: 0.72rem; | |
| } | |
| .score-pill.good { background: var(--green-glow); color: var(--green); } | |
| .score-pill.mid { background: var(--amber-glow); color: var(--amber); } | |
| .score-pill.low { background: var(--red-glow); color: var(--red); } | |
| .arch-diagram { | |
| display: flex; align-items: center; justify-content: center; gap: 24px; | |
| padding: 28px 20px; flex-wrap: wrap; | |
| } | |
| .arch-node { | |
| display: flex; flex-direction: column; align-items: center; gap: 8px; | |
| padding: 18px 20px; border-radius: var(--radius); border: 1px solid var(--border); | |
| background: var(--surface); min-width: 140px; text-align: center; | |
| transition: transform 0.2s; | |
| } | |
| .arch-node:hover { transform: translateY(-3px); } | |
| .arch-arrow { font-size: 1.4rem; color: var(--text-muted); } | |
| .arch-node-name { font-size: 0.75rem; font-weight: 700; } | |
| .arch-node-desc { font-size: 0.6rem; color: var(--text-dim); line-height: 1.3; } | |
| footer { | |
| margin-top: 28px; text-align: center; padding: 16px; | |
| font-size: 0.65rem; color: var(--text-muted); border-top: 1px solid var(--border); | |
| } | |
| footer a { color: var(--indigo); text-decoration: none; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header> | |
| <div class="logo"> | |
| <div class="logo-icon">🕵</div> | |
| <div> | |
| <h1>CounterFeint</h1> | |
| <span>Multi-Agent Ad Fraud Arena · OpenEnv</span> | |
| </div> | |
| </div> | |
| <div class="header-badges"> | |
| <span class="badge badge-accent">GRPO</span> | |
| <span class="badge badge-accent">OpenEnv</span> | |
| <span class="badge badge-green"><span class="pulse"></span> Live</span> | |
| </div> | |
| </header> | |
| <div class="tab-bar"> | |
| <button class="tab-btn active" data-tab="arena" data-tooltip="Watch agents compete in real-time. Fraudster vs Investigator with live reward tracking and step-by-step replay.">⚔ Arena</button> | |
| <button class="tab-btn" data-tab="playground" data-tooltip="Hands-on single-agent mode. Investigate ads yourself, render verdicts, and earn rewards under an action budget.">🔍 Playground</button> | |
| <button class="tab-btn" data-tab="results" data-tooltip="Training overview, baseline scores, reward design details, and animated model comparison reward curves.">📈 Results</button> | |
| </div> | |
| <!-- ════════════════════════ ARENA TAB ════════════════════════ --> | |
| <div class="tab-content active" id="tab-arena"> | |
| <div class="guide-banner arena"> | |
| <strong>Welcome to the CounterFeint Arena!</strong> | |
| Click <strong>Run Auto Match</strong> to watch a live simulation using <strong>deterministic scripted agents</strong> — a reactive Fraudster, a heuristic Investigator, and a rule-based Auditor. | |
| Watch reward curves evolve in real-time as agents compete step by step. For <strong>LLM-based agent</strong> results and trained model comparisons, check the <strong>Results</strong> tab. | |
| </div> | |
| <!-- Architecture overview — Auditor oversees from above --> | |
| <div class="panel" style="margin-bottom:18px"> | |
| <div class="panel-body"> | |
| <div style="display:flex;flex-direction:column;align-items:center;padding:24px 20px;gap:0;"> | |
| <!-- Auditor (top tier — oversight) --> | |
| <div class="arch-node" style="border-color:rgba(245,158,11,0.35);box-shadow:0 0 24px var(--amber-glow);z-index:1;"> | |
| <div class="agent-icon auditor"> | |
| <svg viewBox="0 0 40 40" width="26" height="26"><rect x="6" y="12" width="28" height="22" rx="5" fill="none" stroke="#f59e0b" stroke-width="2.5"/><circle cx="15" cy="22" r="3" fill="#f59e0b"/><circle cx="25" cy="22" r="3" fill="#f59e0b"/><line x1="14" y1="30" x2="26" y2="30" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/><line x1="20" y1="5" x2="20" y2="12" stroke="#f59e0b" stroke-width="2"/><line x1="14" y1="7" x2="26" y2="7" stroke="#f59e0b" stroke-width="2.5" stroke-linecap="round"/><circle cx="14" cy="9" r="2" fill="#f59e0b" opacity="0.6"/><circle cx="26" cy="9" r="2" fill="#f59e0b" opacity="0.6"/></svg> | |
| </div> | |
| <span class="agent-label auditor">Auditor</span> | |
| <span class="arch-node-desc">Post-hoc reasoning &<br/>plausibility auditor</span> | |
| </div> | |
| <!-- Hierarchy arrows --> | |
| <svg width="340" height="40" style="display:block;overflow:visible;flex-shrink:0;" viewBox="0 0 340 40"> | |
| <line x1="170" y1="0" x2="60" y2="36" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="5,3" opacity="0.5"/> | |
| <polygon points="56,32 60,40 66,34" fill="#f59e0b" opacity="0.5"/> | |
| <line x1="170" y1="0" x2="280" y2="36" stroke="#f59e0b" stroke-width="1.5" stroke-dasharray="5,3" opacity="0.5"/> | |
| <polygon points="274,34 280,40 284,32" fill="#f59e0b" opacity="0.5"/> | |
| <text x="170" y="32" text-anchor="middle" fill="#f59e0b" font-size="9" font-family="Inter,sans-serif" font-weight="600" letter-spacing="1" opacity="0.7">AUDITS BOTH AGENTS</text> | |
| </svg> | |
| <!-- Bottom tier: Fraudster → Queue → Investigator --> | |
| <div style="display:flex;align-items:center;justify-content:center;gap:24px;flex-wrap:wrap;"> | |
| <div class="arch-node" style="border-color:rgba(239,68,68,0.3);"> | |
| <div class="agent-icon fraudster"> | |
| <svg viewBox="0 0 40 40" width="26" height="26"><rect x="6" y="12" width="28" height="22" rx="5" fill="none" stroke="#ef4444" stroke-width="2.5"/><circle cx="15" cy="22" r="3" fill="#ef4444"/><circle cx="25" cy="22" r="3" fill="#ef4444"/><path d="M14 30 Q20 35 26 30" fill="none" stroke="#ef4444" stroke-width="2"/><line x1="12" y1="12" x2="8" y2="4" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round"/><line x1="28" y1="12" x2="32" y2="4" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round"/></svg> | |
| </div> | |
| <span class="agent-label fraudster">Fraudster</span> | |
| <span class="arch-node-desc">Proposes & modifies<br/>deceptive ads</span> | |
| </div> | |
| <span class="arch-arrow">➡</span> | |
| <div class="arch-node" style="border-color:rgba(6,182,212,0.3);min-width:160px;"> | |
| <div style="font-size:22px">📜</div> | |
| <span class="arch-node-name" style="color:var(--cyan)">Shared Ad Queue</span> | |
| <span class="arch-node-desc">Ads accumulate here.<br/>Both agents see it.</span> | |
| </div> | |
| <span class="arch-arrow">➡</span> | |
| <div class="arch-node" style="border-color:rgba(99,102,241,0.3);"> | |
| <div class="agent-icon investigator"> | |
| <svg viewBox="0 0 40 40" width="26" height="26"><rect x="6" y="12" width="28" height="22" rx="5" fill="none" stroke="#6366f1" stroke-width="2.5"/><circle cx="15" cy="22" r="3" fill="#6366f1"/><circle cx="25" cy="22" r="3" fill="#6366f1"/><line x1="14" y1="30" x2="26" y2="30" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/><circle cx="20" cy="8" r="3" fill="#6366f1" opacity="0.6"/><line x1="20" y1="11" x2="20" y2="12" stroke="#6366f1" stroke-width="2"/></svg> | |
| </div> | |
| <span class="agent-label investigator">Investigator</span> | |
| <span class="arch-node-desc">Investigates ads &<br/>renders verdicts</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Arena controls --> | |
| <div class="arena-controls"> | |
| <select id="arena-task"> | |
| <option value="task_1">Task 1 — Basic triage (5 ads)</option> | |
| <option value="task_2">Task 2 — Sophisticated fraud (12 ads)</option> | |
| <option value="task_3">Task 3 — Fraud networks (20 ads)</option> | |
| </select> | |
| <input type="number" id="arena-seed" value="77" min="0" style="width:80px;padding:10px;border-radius:var(--radius-sm);background:#0f172a;color:#f1f5f9;border:1px solid rgba(148,163,184,0.35);font-family:inherit;font-size:0.78rem;" placeholder="Seed" /> | |
| <button class="btn btn-primary" id="btn-auto">▶ Run Auto Match</button> | |
| <span id="arena-status" style="font-size:0.72rem;color:var(--text-dim);margin-left:8px;">Ready</span> | |
| </div> | |
| <!-- Phase bar --> | |
| <div class="phase-bar" id="phase-bar"> | |
| <div class="phase-step f" id="ph-fraudster">🤖 Fraudster Turn</div> | |
| <span class="phase-arrow">→</span> | |
| <div class="phase-step i" id="ph-investigator">🔍 Investigator Turn</div> | |
| <span class="phase-arrow">→</span> | |
| <div class="phase-step a" id="ph-auditor">⚖ Audit Phase</div> | |
| <span class="phase-arrow">→</span> | |
| <div class="phase-step d" id="ph-done">✔ Done</div> | |
| </div> | |
| <!-- Arena stats --> | |
| <div class="arena-stats"> | |
| <div class="stat-card"><div class="stat-label">Round</div><div class="stat-value" id="ar-round" style="color:var(--cyan)">-</div></div> | |
| <div class="stat-card"><div class="stat-label">Total Steps</div><div class="stat-value" id="ar-steps" style="color:var(--text)">-</div></div> | |
| <div class="stat-card"><div class="stat-label">Proposals Used</div><div class="stat-value" id="ar-proposals" style="color:var(--amber)">-</div></div> | |
| <div class="stat-card"><div class="stat-label">Grader Score</div><div class="stat-value" id="ar-grader" style="color:var(--green)">-</div></div> | |
| <div class="stat-card"><div class="stat-label">End Reason</div><div class="stat-value" id="ar-reason" style="font-size:0.85rem;color:var(--text-dim)">-</div></div> | |
| </div> | |
| <!-- Reward chart (Fraudster + Investigator only) --> | |
| <div class="chart-container"> | |
| <div class="chart-title">Agent Reward Trajectories</div> | |
| <div id="arena-chart" style="width:100%;height:220px;"></div> | |
| <div class="chart-legend"> | |
| <div class="chart-legend-item"><div class="chart-legend-dot" style="background:var(--red)"></div>Fraudster</div> | |
| <div class="chart-legend-item"><div class="chart-legend-dot" style="background:var(--indigo)"></div>Investigator</div> | |
| </div> | |
| </div> | |
| <!-- Main 3-panel grid --> | |
| <div class="arena-main"> | |
| <!-- Fraudster panel --> | |
| <div class="panel"> | |
| <div class="agent-hdr"> | |
| <div class="agent-icon fraudster"> | |
| <svg viewBox="0 0 40 40" width="24" height="24"><rect x="6" y="12" width="28" height="22" rx="5" fill="none" stroke="#ef4444" stroke-width="2.5"/><circle cx="15" cy="22" r="3" fill="#ef4444"/><circle cx="25" cy="22" r="3" fill="#ef4444"/><path d="M14 30 Q20 35 26 30" fill="none" stroke="#ef4444" stroke-width="2"/><line x1="12" y1="12" x2="8" y2="4" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round"/><line x1="28" y1="12" x2="32" y2="4" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round"/></svg> | |
| </div> | |
| <div class="agent-hdr-info"> | |
| <div class="agent-name" style="color:var(--red)">Fraudster</div> | |
| <div class="agent-desc">Adversarial ad proposer</div> | |
| </div> | |
| <div class="reward-badge zero" id="ar-f-reward">0.00</div> | |
| </div> | |
| <div class="panel-body"> | |
| <div class="agent-actions-scroll" id="ar-f-actions"><span class="ph-text" style="color:var(--text-dim);font-size:0.72rem">Run a match to see fraudster actions.</span></div> | |
| </div> | |
| </div> | |
| <!-- Queue panel --> | |
| <div class="panel"> | |
| <div class="panel-hdr"> | |
| <span class="panel-title" style="color:var(--cyan)">📜 Ad Queue</span> | |
| <span style="font-size:0.65rem;color:var(--text-dim)" id="ar-q-count">0 ads</span> | |
| </div> | |
| <div class="panel-body"> | |
| <div class="queue-grid" id="ar-queue"> | |
| <span style="color:var(--text-dim);font-size:0.75rem">No ads yet</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Investigator panel --> | |
| <div class="panel"> | |
| <div class="agent-hdr"> | |
| <div class="agent-icon investigator"> | |
| <svg viewBox="0 0 40 40" width="24" height="24"><rect x="6" y="12" width="28" height="22" rx="5" fill="none" stroke="#6366f1" stroke-width="2.5"/><circle cx="15" cy="22" r="3" fill="#6366f1"/><circle cx="25" cy="22" r="3" fill="#6366f1"/><line x1="14" y1="30" x2="26" y2="30" stroke="#6366f1" stroke-width="2" stroke-linecap="round"/><circle cx="20" cy="8" r="3" fill="#6366f1" opacity="0.6"/><line x1="20" y1="11" x2="20" y2="12" stroke="#6366f1" stroke-width="2"/></svg> | |
| </div> | |
| <div class="agent-hdr-info"> | |
| <div class="agent-name" style="color:var(--indigo)">Investigator</div> | |
| <div class="agent-desc">Evidence-based reviewer</div> | |
| </div> | |
| <div class="reward-badge zero" id="ar-i-reward">0.00</div> | |
| </div> | |
| <div class="panel-body"> | |
| <div class="agent-actions-scroll" id="ar-i-actions"><span class="ph-text" style="color:var(--text-dim);font-size:0.72rem">Run a match to see investigator actions.</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Auditor panel --> | |
| <div class="panel" id="ar-auditor-panel"> | |
| <div class="agent-hdr"> | |
| <div class="agent-icon auditor"> | |
| <svg viewBox="0 0 40 40" width="24" height="24"><rect x="6" y="12" width="28" height="22" rx="5" fill="none" stroke="#f59e0b" stroke-width="2.5"/><circle cx="15" cy="22" r="3" fill="#f59e0b"/><circle cx="25" cy="22" r="3" fill="#f59e0b"/><line x1="14" y1="30" x2="26" y2="30" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/><line x1="20" y1="5" x2="20" y2="12" stroke="#f59e0b" stroke-width="2"/><line x1="14" y1="7" x2="26" y2="7" stroke="#f59e0b" stroke-width="2.5" stroke-linecap="round"/><circle cx="14" cy="9" r="2" fill="#f59e0b" opacity="0.6"/><circle cx="26" cy="9" r="2" fill="#f59e0b" opacity="0.6"/></svg> | |
| </div> | |
| <div class="agent-hdr-info"> | |
| <div class="agent-name" style="color:var(--amber)">Auditor</div> | |
| <div class="agent-desc">Post-hoc reasoning & plausibility auditor</div> | |
| </div> | |
| <div class="reward-badge zero" id="ar-a-reward">0.00</div> | |
| </div> | |
| <div class="panel-body" id="ar-audit-body"> | |
| <div style="font-size:0.72rem;color:var(--text-dim)">Auditor acts after the match concludes. Run a match to see audit results.</div> | |
| </div> | |
| </div> | |
| <!-- Full trace timeline --> | |
| <div class="panel"> | |
| <div class="panel-hdr"> | |
| <span class="panel-title">🕑 Match Timeline</span> | |
| <span style="font-size:0.65rem;color:var(--text-dim)" id="ar-trace-count">0 events</span> | |
| </div> | |
| <div class="trace-timeline" id="ar-timeline"></div> | |
| </div> | |
| </div> | |
| <!-- ════════════════════════ PLAYGROUND TAB ════════════════════════ --> | |
| <div class="tab-content" id="tab-playground"> | |
| <div class="guide-banner playground"> | |
| <strong>Step into the Investigator's shoes!</strong> | |
| Select a task difficulty below, click <strong>Reset environment</strong> to begin, then investigate ads by examining advertiser histories, landing pages, and payment methods. | |
| Render your verdict (approve, reject, or escalate) on each ad. Every investigation costs budget — balance thoroughness against efficiency to maximize your cumulative reward. | |
| </div> | |
| <div class="pg-stats"> | |
| <div class="stat-card"><div class="stat-label">Total ads</div><div class="stat-value" id="st-total">-</div></div> | |
| <div class="stat-card"><div class="stat-label">Reviewed</div><div class="stat-value" id="st-reviewed" style="color:var(--green)">-</div></div> | |
| <div class="stat-card"><div class="stat-label">Budget left</div><div class="stat-value" id="st-budget" style="color:#f472b6">-</div></div> | |
| <div class="stat-card"><div class="stat-label">Step</div><div class="stat-value" id="st-step" style="color:var(--amber)">-</div></div> | |
| <div class="stat-card"><div class="stat-label">Env score</div><div class="stat-value" id="st-score" style="color:var(--cyan)">-</div></div> | |
| <div class="stat-card"><div class="stat-label">Cum. reward</div><div class="stat-value" id="st-cum">-</div></div> | |
| </div> | |
| <div class="chart-container" style="margin-bottom:16px;width:55%;min-width:380px;"> | |
| <div class="chart-title">Cumulative Reward</div> | |
| <div id="pg-chart" style="width:100%;height:300px;"></div> | |
| </div> | |
| <div style="display:flex;gap:10px;margin-bottom:18px;flex-wrap:wrap;align-items:center"> | |
| <select id="task-select" style="padding:10px 14px;border-radius:var(--radius-sm);font-size:0.78rem;font-family:inherit;background:#0f172a;color:#f1f5f9;border:1px solid rgba(148,163,184,0.35);"> | |
| <option value="task_1">Task 1 — Basic triage</option> | |
| <option value="task_2">Task 2 — Sophisticated fraud</option> | |
| <option value="task_3">Task 3 — Fraud networks</option> | |
| </select> | |
| <button class="btn btn-primary" id="btn-reset">Reset environment</button> | |
| <button class="btn btn-green" id="btn-step" disabled>Execute action</button> | |
| <button class="btn btn-amber" id="btn-score">Get grader score</button> | |
| <button class="btn btn-ghost" onclick="window.open('/docs','_blank')">API docs</button> | |
| </div> | |
| <div style="padding:10px 14px;border-radius:var(--radius-sm);border:1px solid var(--border);margin-bottom:16px;font-size:0.78rem;color:var(--text-dim)" id="feedback">Select a task and reset to begin.</div> | |
| <div class="pg-grid"> | |
| <div> | |
| <div class="panel"> | |
| <div class="panel-hdr"><span class="panel-title">Ad queue</span></div> | |
| <div class="panel-body"><div class="ad-queue" id="ad-queue"></div></div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-hdr"><span class="panel-title">Subject profile</span></div> | |
| <div class="panel-body" id="profile-body"></div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-hdr"><span class="panel-title">Investigation findings</span></div> | |
| <div class="panel-body"><div class="inv-grid" id="findings-grid"></div></div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-hdr"> | |
| <span class="panel-title">RL intelligence log</span> | |
| <button class="btn btn-ghost" style="padding:4px 10px;font-size:0.62rem;" id="btn-clear-log">Clear</button> | |
| </div> | |
| <div class="panel-body"><div class="log-area" id="log-area"></div></div> | |
| </div> | |
| </div> | |
| <div> | |
| <div class="panel"> | |
| <div class="panel-hdr"><span class="panel-title">Take action</span></div> | |
| <div class="panel-body"> | |
| <div class="action-form"> | |
| <div class="form-group"> | |
| <label>Action type</label> | |
| <select id="act-type"> | |
| <option value="investigate">Investigate</option> | |
| <option value="verdict">Verdict</option> | |
| <option value="link_accounts">Link accounts</option> | |
| </select> | |
| </div> | |
| <div class="form-group"><label>Ad ID</label><select id="act-ad"></select></div> | |
| <div class="form-group" id="grp-target"> | |
| <label>Investigation target</label> | |
| <select id="act-target"> | |
| <option value="advertiser_history">advertiser_history</option> | |
| <option value="landing_page">landing_page</option> | |
| <option value="payment_method">payment_method</option> | |
| <option value="targeting_overlap">targeting_overlap</option> | |
| <option value="campaign_structure">campaign_structure</option> | |
| <option value="policy_classifier">policy_classifier</option> | |
| </select> | |
| </div> | |
| <div class="form-group hidden" id="grp-verdict"> | |
| <label>Verdict</label> | |
| <select id="act-verdict"><option value="approve">approve</option><option value="reject">reject</option><option value="escalate">escalate</option></select> | |
| </div> | |
| <div class="form-group hidden" id="grp-conf"> | |
| <label>Confidence (0-1)</label> | |
| <input type="number" id="act-conf" min="0" max="1" step="0.05" value="0.85" /> | |
| </div> | |
| <div class="form-group hidden" id="grp-link"><label>Linked ad ID</label><select id="act-linked"></select></div> | |
| <div class="form-group hidden" id="grp-reason"> | |
| <label>Link reason</label> | |
| <textarea id="act-reason" placeholder="Why are these ads connected?"></textarea> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-hdr"><span class="panel-title">Verdict history</span></div> | |
| <div class="panel-body" id="verdict-list"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- ════════════════════════ RESULTS TAB ════════════════════════ --> | |
| <div class="tab-content" id="tab-results"> | |
| <div class="guide-banner results"> | |
| <strong>Training overview & model comparison.</strong> | |
| Explore baseline scores, reward design, and the GRPO training pipeline below. | |
| Use <strong>Run Demo Match</strong> to see a live animated simulation. Model comparison curves will be updated as new models are trained and evaluated. | |
| </div> | |
| <div class="results-grid"> | |
| <div class="panel"> | |
| <div class="panel-hdr"><span class="panel-title">GRPO Training Curves — Qwen3-0.6B</span></div> | |
| <div class="panel-body" style="padding:12px;text-align:center;"> | |
| <img src="/static/loss-reward-kl-curve.png" alt="GRPO training — Loss, Mean Reward, KL Divergence" style="width:100%;max-width:720px;border-radius:8px;border:1px solid var(--border);" /> | |
| <div style="margin-top:10px;font-size:0.68rem;color:var(--text-muted);line-height:1.4"> | |
| GRPO loss converges, mean reward trends upward, and KL divergence stays controlled — indicating stable policy improvement over 25 training steps. | |
| </div> | |
| </div> | |
| </div> | |
| <div class="panel"> | |
| <div class="panel-hdr"><span class="panel-title">Reward Design</span></div> | |
| <div class="panel-body"> | |
| <table class="results-table"> | |
| <thead><tr><th>Action</th><th>Reward</th><th>Rationale</th></tr></thead> | |
| <tbody> | |
| <tr><td>Investigation</td><td style="color:var(--amber)">-0.02</td><td style="color:var(--text-dim)">Time/latency cost</td></tr> | |
| <tr><td>Correct rejection</td><td style="color:var(--green)">+0.30 to +0.40</td><td style="color:var(--text-dim)">Scaled by severity</td></tr> | |
| <tr><td>Correct approval</td><td style="color:var(--green)">+0.10</td><td style="color:var(--text-dim)">Revenue preserved</td></tr> | |
| <tr><td>False positive</td><td style="color:var(--red)">-0.35</td><td style="color:var(--text-dim)">Lost advertiser revenue</td></tr> | |
| <tr><td>False negative</td><td style="color:var(--red)">-0.50</td><td style="color:var(--text-dim)">Fraud goes live</td></tr> | |
| <tr><td>Correct link</td><td style="color:var(--green)">+0.40</td><td style="color:var(--text-dim)">Ring detection</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Multi-agent reward diagram --> | |
| <div class="panel" style="margin-top:16px"> | |
| <div class="panel-hdr"><span class="panel-title">Multi-Agent Reward Functions</span></div> | |
| <div class="panel-body"> | |
| <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px"> | |
| <div style="padding:14px;border-radius:var(--radius-sm);border:1px solid rgba(239,68,68,0.2);background:rgba(239,68,68,0.04)"> | |
| <div style="font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.7px;color:var(--red);margin-bottom:8px">Fraudster Reward</div> | |
| <div style="font-size:0.72rem;color:var(--text-dim);line-height:1.5"> | |
| <code style="color:var(--text);font-size:0.68rem">∑ severity × plausibility</code> for fraud ads <strong style="color:var(--green)">not rejected</strong>, minus penalty per <strong style="color:var(--red)">rejected</strong> ad. Higher plausibility = more reward for evasion. | |
| </div> | |
| </div> | |
| <div style="padding:14px;border-radius:var(--radius-sm);border:1px solid rgba(99,102,241,0.2);background:rgba(99,102,241,0.04)"> | |
| <div style="font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.7px;color:var(--indigo);margin-bottom:8px">Investigator Reward</div> | |
| <div style="font-size:0.72rem;color:var(--text-dim);line-height:1.5"> | |
| Base grader score + plausibility-weighted <strong style="color:var(--green)">clean rationale bonus</strong> − capped inconsistency penalty. Track A flags strip the bonus. | |
| </div> | |
| </div> | |
| <div style="padding:14px;border-radius:var(--radius-sm);border:1px solid rgba(245,158,11,0.2);background:rgba(245,158,11,0.04)"> | |
| <div style="font-size:0.65rem;font-weight:700;text-transform:uppercase;letter-spacing:0.7px;color:var(--amber);margin-bottom:8px">Auditor Reward</div> | |
| <div style="font-size:0.72rem;color:var(--text-dim);line-height:1.5"> | |
| Reward for <strong style="color:var(--green)">true-positive</strong> flags vs ground truth, minus false-positive penalty. Deterministic rule-based scorecards. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Training pipeline --> | |
| <div class="panel" style="margin-top:16px"> | |
| <div class="panel-hdr"><span class="panel-title">Training Pipeline — GRPO Self-Play</span></div> | |
| <div class="panel-body"> | |
| <div class="arch-diagram" style="padding:20px 16px"> | |
| <div class="arch-node" style="border-color:rgba(239,68,68,0.3)"> | |
| <div style="font-size:18px">🤖</div> | |
| <span class="arch-node-name" style="color:var(--red)">Frozen Fraudster</span> | |
| <span class="arch-node-desc">llama3.1:8b via Ollama<br/>(8B params, frozen)</span> | |
| </div> | |
| <span class="arch-arrow">💥</span> | |
| <div class="arch-node" style="border-color:rgba(99,102,241,0.4);box-shadow:0 0 20px var(--indigo-glow)"> | |
| <div style="font-size:18px">🤖</div> | |
| <span class="arch-node-name" style="color:var(--indigo)">Trainable Investigator</span> | |
| <span class="arch-node-desc">Qwen3-0.6B + QLoRA<br/>(GRPO training)</span> | |
| </div> | |
| <span class="arch-arrow">📋</span> | |
| <div class="arch-node" style="border-color:rgba(245,158,11,0.3)"> | |
| <div style="font-size:18px">⚖</div> | |
| <span class="arch-node-name" style="color:var(--amber)">Deterministic Auditor</span> | |
| <span class="arch-node-desc">Rule-based scorecards<br/>(reward source)</span> | |
| </div> | |
| </div> | |
| <div style="text-align:center;font-size:0.7rem;color:var(--text-muted);margin-top:4px"> | |
| Sequential self-play: train one agent at a time against frozen opponents (AlphaGo paradigm) | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Live demo match (animated like arena) --> | |
| <div class="panel" style="margin-top:16px"> | |
| <div class="panel-hdr"> | |
| <span class="panel-title">📈 Live Match Reward Curves</span> | |
| <button class="btn btn-primary" id="btn-results-demo" style="padding:6px 14px;font-size:0.68rem">Run Demo Match</button> | |
| </div> | |
| <div class="panel-body"> | |
| <div id="results-chart" style="width:100%;height:220px;margin-bottom:12px"> | |
| <div style="color:var(--text-dim);font-size:0.78rem;text-align:center;padding:50px 20px">Click “Run Demo Match” to generate animated reward curves from a live simulation.</div> | |
| </div> | |
| <div class="chart-legend" id="results-legend" style="display:none"> | |
| <div class="chart-legend-item"><div class="chart-legend-dot" style="background:var(--red)"></div>Fraudster</div> | |
| <div class="chart-legend-item"><div class="chart-legend-dot" style="background:var(--indigo)"></div>Investigator</div> | |
| </div> | |
| <div id="results-summary" style="margin-top:14px"></div> | |
| </div> | |
| </div> | |
| <!-- Model comparison (placeholder — data to be added) --> | |
| <div class="panel" style="margin-top:16px"> | |
| <div class="panel-hdr"> | |
| <span class="panel-title">🧠 Model Comparison — Investigator Reward Curves</span> | |
| <button class="btn btn-green" id="btn-model-compare" style="padding:6px 14px;font-size:0.68rem" disabled>Play Comparison</button> | |
| </div> | |
| <div class="panel-body"> | |
| <div id="model-chart" style="width:100%;height:220px;margin-bottom:12px"> | |
| <div style="color:var(--text-dim);font-size:0.78rem;text-align:center;padding:50px 20px;line-height:1.7"> | |
| Model comparison curves will appear here as training progresses.<br/> | |
| <span style="font-size:0.68rem;color:var(--text-muted)">Planned models: Untrained Qwen3.5-0.8B, Fine-tuned Qwen3.5-0.8B, and more.</span> | |
| </div> | |
| </div> | |
| <div class="chart-legend" id="model-legend" style="display:none"> | |
| <div class="chart-legend-item"><div class="chart-legend-dot" style="background:#94a3b8"></div>Untrained Qwen3.5-0.8B</div> | |
| <div class="chart-legend-item"><div class="chart-legend-dot" style="background:#22c55e"></div>Fine-tuned Qwen3.5-0.8B</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <footer> | |
| CounterFeint — Multi-Agent Ad Fraud Arena · | |
| <a href="/docs">API Docs</a> · | |
| <a href="/tasks">Tasks</a> · | |
| <a href="/grader">Grader</a> · | |
| <a href="/schema">Schema</a> · | |
| <a href="/matches">Matches</a> | |
| </footer> | |
| </div> | |
| <div class="toast-container" id="toasts"></div> | |
| <script> | |
| /* ═══════════════════ Shared helpers ═══════════════════ */ | |
| const API = ''; | |
| function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } | |
| function toast(msg, type) { | |
| const c = document.getElementById('toasts'); | |
| const t = document.createElement('div'); | |
| t.className = 'toast ' + (type || 'info'); | |
| t.textContent = msg; | |
| c.appendChild(t); | |
| setTimeout(() => t.remove(), 3200); | |
| } | |
| function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } | |
| /* ═══════════════════ Tab switching ═══════════════════ */ | |
| document.querySelectorAll('.tab-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); | |
| document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); | |
| btn.classList.add('active'); | |
| document.getElementById('tab-' + btn.dataset.tab).classList.add('active'); | |
| }); | |
| }); | |
| /* ═══════════════════ SVG Chart renderer ═══════════════════ */ | |
| function renderMultiChart(container, datasets, options = {}) { | |
| const el = typeof container === 'string' ? document.getElementById(container) : container; | |
| const w = options.width || el.clientWidth || 600; | |
| const h = options.height || el.clientHeight || 160; | |
| const pad = { t: 20, r: 52, b: 28, l: 52 }; | |
| const cw = w - pad.l - pad.r; | |
| const ch = h - pad.t - pad.b; | |
| let allVals = []; | |
| datasets.forEach(ds => allVals.push(...ds.data)); | |
| if (!allVals.length) { el.innerHTML = '<div style="color:var(--text-dim);font-size:0.75rem;text-align:center;padding:30px">No data</div>'; return; } | |
| let mn = Math.min(0, ...allVals), mx = Math.max(0, ...allVals); | |
| if (mn === mx) { mn -= 0.1; mx += 0.1; } | |
| const range = mx - mn; | |
| mn -= range * 0.05; | |
| mx += range * 0.05; | |
| const maxLen = options.totalSteps || Math.max(...datasets.map(ds => ds.data.length)); | |
| function sx(i) { return pad.l + (maxLen <= 1 ? cw / 2 : (i / (maxLen - 1)) * cw); } | |
| function sy(v) { return pad.t + ch - ((v - mn) / (mx - mn)) * ch; } | |
| let svg = `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" style="display:block;width:100%;height:100%">`; | |
| svg += `<rect width="${w}" height="${h}" fill="rgba(0,0,0,0.2)" rx="8"/>`; | |
| const gridLines = 5; | |
| for (let g = 0; g <= gridLines; g++) { | |
| const yy = pad.t + (g / gridLines) * ch; | |
| const val = mx - (g / gridLines) * (mx - mn); | |
| svg += `<line x1="${pad.l}" y1="${yy}" x2="${w - pad.r}" y2="${yy}" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>`; | |
| svg += `<text x="${pad.l - 8}" y="${yy + 4}" fill="rgba(255,255,255,0.55)" font-size="11" text-anchor="end" font-family="Inter,sans-serif" font-weight="500">${val.toFixed(2)}</text>`; | |
| } | |
| const zeroY = sy(0); | |
| if (zeroY > pad.t + 2 && zeroY < h - pad.b - 2) { | |
| svg += `<line x1="${pad.l}" y1="${zeroY}" x2="${w - pad.r}" y2="${zeroY}" stroke="rgba(255,255,255,0.15)" stroke-width="1" stroke-dasharray="4,3"/>`; | |
| svg += `<text x="${pad.l - 8}" y="${zeroY + 4}" fill="rgba(255,255,255,0.5)" font-size="10" text-anchor="end" font-family="Inter,sans-serif" font-weight="600">0</text>`; | |
| } | |
| datasets.forEach(ds => { | |
| if (!ds.data.length) return; | |
| const pts = ds.data.map((v, i) => `${sx(i)},${sy(v)}`).join(' '); | |
| const gradId = 'g' + Math.random().toString(36).slice(2, 8); | |
| const lastX = sx(ds.data.length - 1); | |
| const lastY = sy(ds.data[ds.data.length - 1]); | |
| svg += `<defs><linearGradient id="${gradId}" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="${ds.color}" stop-opacity="0.25"/><stop offset="100%" stop-color="${ds.color}" stop-opacity="0"/></linearGradient></defs>`; | |
| const areaPath = `M${sx(0)},${sy(ds.data[0])} ` + ds.data.map((v, i) => `L${sx(i)},${sy(v)}`).join(' ') + ` L${lastX},${h - pad.b} L${sx(0)},${h - pad.b} Z`; | |
| svg += `<path d="${areaPath}" fill="url(#${gradId})"/>`; | |
| svg += `<polyline fill="none" stroke="${ds.color}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" points="${pts}"/>`; | |
| svg += `<circle cx="${lastX}" cy="${lastY}" r="4" fill="${ds.color}" stroke="rgba(0,0,0,0.3)" stroke-width="1"/>`; | |
| const labelX = lastX + 8; | |
| const labelVal = ds.data[ds.data.length - 1].toFixed(2); | |
| svg += `<text x="${labelX}" y="${lastY + 4}" fill="${ds.color}" font-size="12" font-weight="700" font-family="Inter,sans-serif">${labelVal}</text>`; | |
| }); | |
| const stepLabels = [0, Math.floor(maxLen / 2), maxLen - 1]; | |
| stepLabels.forEach(i => { | |
| if (i < 0 || maxLen <= 0) return; | |
| svg += `<text x="${sx(i)}" y="${h - 6}" fill="rgba(255,255,255,0.4)" font-size="10" text-anchor="middle" font-family="Inter,sans-serif">${i}</text>`; | |
| }); | |
| svg += `<text x="${w / 2}" y="${h - 6}" fill="rgba(255,255,255,0.25)" font-size="9" text-anchor="middle" font-family="Inter,sans-serif"></text>`; | |
| svg += '</svg>'; | |
| el.innerHTML = svg; | |
| } | |
| /* ═══════════════════ ARENA TAB ═══════════════════ */ | |
| let arenaAnimating = false; | |
| let arenaAbort = false; | |
| function updatePhaseBar(phase) { | |
| ['ph-fraudster','ph-investigator','ph-auditor','ph-done'].forEach(id => { | |
| document.getElementById(id).classList.remove('active','done-phase'); | |
| }); | |
| const map = { fraudster_turn: 'ph-fraudster', investigator_turn: 'ph-investigator', audit_phase: 'ph-auditor', done: 'ph-done' }; | |
| const order = ['fraudster_turn','investigator_turn','audit_phase','done']; | |
| const idx = order.indexOf(phase); | |
| for (let i = 0; i < idx; i++) { | |
| document.getElementById(map[order[i]]).classList.add('done-phase'); | |
| } | |
| if (map[phase]) document.getElementById(map[phase]).classList.add('active'); | |
| } | |
| function updateRewardBadge(id, value) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| const cls = value > 0.001 ? 'pos' : value < -0.001 ? 'neg' : 'zero'; | |
| el.className = 'reward-badge ' + cls; | |
| el.textContent = (value >= 0 ? '+' : '') + value.toFixed(2); | |
| } | |
| function appendActionEntry(containerId, entry, color) { | |
| const el = document.getElementById(containerId); | |
| const ph = el.querySelector('.ph-text'); | |
| if (ph) ph.remove(); | |
| const div = document.createElement('div'); | |
| div.style.cssText = 'padding:6px 0;border-bottom:1px solid var(--border);font-size:0.72rem;animation:fadeIn 0.3s ease'; | |
| const rStyle = entry.reward > 0 ? 'color:var(--green)' : entry.reward < 0 ? 'color:var(--red)' : 'color:var(--text-muted)'; | |
| div.innerHTML = `<span style="color:${color};font-weight:600">${esc(entry.action_type)}</span> <span style="color:var(--text-dim)">${esc(entry.detail)}</span> <span style="${rStyle};font-weight:600;float:right">${entry.reward >= 0 ? '+' : ''}${entry.reward.toFixed(3)}</span>`; | |
| el.appendChild(div); | |
| el.scrollTop = el.scrollHeight; | |
| } | |
| function appendTimelineEntry(entry) { | |
| const timeline = document.getElementById('ar-timeline'); | |
| const isAuditor = entry.role === 'auditor'; | |
| const rCls = isAuditor ? '' : (entry.reward > 0 ? 'pos' : entry.reward < 0 ? 'neg' : ''); | |
| const rewardText = isAuditor ? '-' : `${entry.reward >= 0 ? '+' : ''}${entry.reward.toFixed(3)}`; | |
| const div = document.createElement('div'); | |
| div.className = 'trace-entry'; | |
| div.innerHTML = `<div class="trace-dot ${entry.role}"></div><div class="trace-text"><strong>${entry.role}</strong> ${esc(entry.detail)}</div><div class="trace-reward ${rCls}">${rewardText}</div>`; | |
| timeline.appendChild(div); | |
| timeline.scrollTop = timeline.scrollHeight; | |
| document.getElementById('ar-trace-count').textContent = timeline.children.length + ' events'; | |
| } | |
| function updateArenaQueue(trace, upToIdx) { | |
| const verdictMap = {}; | |
| const proposedAds = new Set(); | |
| for (let i = 0; i <= upToIdx; i++) { | |
| const t = trace[i]; | |
| if (t.role === 'investigator' && t.action_type === 'verdict') { | |
| const m = t.detail.match(/Verdict on (ad_\d+): (\w+)/); | |
| if (m) verdictMap[m[1]] = m[2]; | |
| } | |
| if (t.role === 'fraudster' && t.action_type === 'propose_ad') { | |
| const m = (t.feedback || '').match(/ad_id=(ad_\d+)/); | |
| if (m) proposedAds.add(m[1]); | |
| } | |
| } | |
| const allAds = [...new Set([...Object.keys(verdictMap), ...proposedAds])].sort(); | |
| const qEl = document.getElementById('ar-queue'); | |
| if (allAds.length) { | |
| qEl.innerHTML = allAds.map(id => { | |
| const v = verdictMap[id] || 'pending'; | |
| const isProp = proposedAds.has(id); | |
| const cls = v === 'approve' ? 'approved' : v === 'reject' ? 'rejected' : v === 'escalate' ? 'escalated' : isProp ? 'proposed' : 'pending'; | |
| const dotColor = cls === 'approved' ? 'var(--green)' : cls === 'rejected' ? 'var(--red)' : cls === 'escalated' ? 'var(--cyan)' : cls === 'proposed' ? 'var(--amber)' : 'var(--text-muted)'; | |
| return `<div class="q-chip ${cls}"><span class="q-dot" style="background:${dotColor}"></span>${id}${isProp ? ' \u2605' : ''}</div>`; | |
| }).join(''); | |
| document.getElementById('ar-q-count').textContent = allAds.length + ' ads'; | |
| } | |
| } | |
| function renderAuditorPanel(data) { | |
| const fs = data.final_state; | |
| updateRewardBadge('ar-a-reward', data.final_rewards.auditor); | |
| const auditBody = document.getElementById('ar-audit-body'); | |
| if (fs.audit_report) { | |
| const ar = fs.audit_report; | |
| const ta = ar.track_a_flags || []; | |
| const tb = ar.track_b_flags || []; | |
| let html = '<div class="audit-grid"><div>'; | |
| html += `<div style="font-size:0.65rem;font-weight:700;text-transform:uppercase;color:var(--indigo);margin-bottom:8px">Track A \u2014 Investigator Audit (score: ${(ar.investigator_audit_score || 0).toFixed(2)})</div>`; | |
| if (ta.length) { | |
| ta.forEach(f => { | |
| const sev = f.severity || 0.5; | |
| const sevCls = sev >= 0.7 ? 'high' : sev >= 0.4 ? 'med' : 'low'; | |
| html += `<div class="flag-entry track-a"><div class="flag-type">${esc(f.flag_type || 'flag')} ${f.target_ad_id ? '(' + esc(f.target_ad_id) + ')' : ''}</div><div style="font-size:0.65rem;color:var(--text-dim);margin-top:2px">${esc((f.note || '').slice(0, 100))}</div><div class="severity-bar"><div class="severity-fill ${sevCls}" style="width:${sev * 100}%"></div></div></div>`; | |
| }); | |
| } else { html += '<div style="font-size:0.72rem;color:var(--text-dim)">No Track A flags.</div>'; } | |
| html += '</div><div>'; | |
| html += `<div style="font-size:0.65rem;font-weight:700;text-transform:uppercase;color:var(--amber);margin-bottom:8px">Track B \u2014 Fraudster Plausibility (score: ${(ar.fraudster_plausibility_score || 0).toFixed(2)})</div>`; | |
| if (tb.length) { | |
| tb.forEach(f => { | |
| const sev = f.severity || 0.5; | |
| const sevCls = sev >= 0.7 ? 'high' : sev >= 0.4 ? 'med' : 'low'; | |
| html += `<div class="flag-entry track-b"><div class="flag-type">${esc(f.flag_type || 'flag')} ${f.target_ad_id ? '(' + esc(f.target_ad_id) + ')' : ''}</div><div style="font-size:0.65rem;color:var(--text-dim);margin-top:2px">${esc((f.note || '').slice(0, 100))}</div><div class="severity-bar"><div class="severity-fill ${sevCls}" style="width:${sev * 100}%"></div></div></div>`; | |
| }); | |
| } else { html += '<div style="font-size:0.72rem;color:var(--text-dim)">No Track B flags.</div>'; } | |
| html += '</div></div>'; | |
| auditBody.innerHTML = html; | |
| } | |
| } | |
| function resetArenaUI() { | |
| document.getElementById('ar-f-actions').innerHTML = '<span class="ph-text" style="color:var(--text-dim);font-size:0.72rem">Waiting...</span>'; | |
| document.getElementById('ar-i-actions').innerHTML = '<span class="ph-text" style="color:var(--text-dim);font-size:0.72rem">Waiting...</span>'; | |
| document.getElementById('ar-timeline').innerHTML = ''; | |
| document.getElementById('ar-queue').innerHTML = '<span style="color:var(--text-dim);font-size:0.75rem">Waiting for match...</span>'; | |
| document.getElementById('ar-q-count').textContent = '0 ads'; | |
| document.getElementById('ar-trace-count').textContent = '0 events'; | |
| updateRewardBadge('ar-f-reward', 0); | |
| updateRewardBadge('ar-i-reward', 0); | |
| updateRewardBadge('ar-a-reward', 0); | |
| document.getElementById('ar-round').textContent = '-'; | |
| document.getElementById('ar-steps').textContent = '0'; | |
| document.getElementById('ar-proposals').textContent = '0'; | |
| document.getElementById('ar-grader').textContent = '-'; | |
| document.getElementById('ar-reason').textContent = 'Running...'; | |
| document.getElementById('ar-audit-body').innerHTML = '<div style="font-size:0.72rem;color:var(--text-dim)">Auditor will act after match concludes...</div>'; | |
| document.getElementById('arena-chart').innerHTML = ''; | |
| updatePhaseBar(''); | |
| } | |
| async function animateArenaMatch(data) { | |
| const trace = data.trace; | |
| const fs = data.final_state; | |
| const totalSteps = trace.length; | |
| let cumF = 0, cumI = 0; | |
| const chartDataF = [], chartDataI = []; | |
| trace.forEach(t => { | |
| if (t.role === 'fraudster') cumF += t.reward; | |
| else if (t.role === 'investigator') cumI += t.reward; | |
| chartDataF.push(cumF); | |
| chartDataI.push(cumI); | |
| }); | |
| for (let i = 0; i < totalSteps; i++) { | |
| if (arenaAbort) return; | |
| const entry = trace[i]; | |
| if (entry.role === 'fraudster') updatePhaseBar('fraudster_turn'); | |
| else if (entry.role === 'investigator') updatePhaseBar('investigator_turn'); | |
| else if (entry.role === 'auditor') updatePhaseBar('audit_phase'); | |
| updateRewardBadge('ar-f-reward', chartDataF[i]); | |
| updateRewardBadge('ar-i-reward', chartDataI[i]); | |
| if (entry.role === 'fraudster') appendActionEntry('ar-f-actions', entry, 'var(--red)'); | |
| else if (entry.role === 'investigator') appendActionEntry('ar-i-actions', entry, 'var(--indigo)'); | |
| updateArenaQueue(trace, i); | |
| appendTimelineEntry(entry); | |
| document.getElementById('ar-steps').textContent = i + 1; | |
| renderMultiChart('arena-chart', [ | |
| { data: chartDataF.slice(0, i + 1), color: '#ef4444', label: 'Fraudster' }, | |
| { data: chartDataI.slice(0, i + 1), color: '#6366f1', label: 'Investigator' }, | |
| ], { height: 220, totalSteps: totalSteps }); | |
| await sleep(600); | |
| } | |
| updatePhaseBar('done'); | |
| document.getElementById('ar-round').textContent = fs.round_number || '-'; | |
| document.getElementById('ar-proposals').textContent = fs.proposals_used || 0; | |
| document.getElementById('ar-grader').textContent = fs.grader_score != null ? fs.grader_score.toFixed(3) : '-'; | |
| document.getElementById('ar-reason').textContent = fs.end_reason || '-'; | |
| await sleep(400); | |
| renderAuditorPanel(data); | |
| } | |
| document.getElementById('btn-auto').onclick = async () => { | |
| if (arenaAnimating) { | |
| arenaAbort = true; | |
| return; | |
| } | |
| const btn = document.getElementById('btn-auto'); | |
| const status = document.getElementById('arena-status'); | |
| btn.innerHTML = '■ Stop'; | |
| btn.className = 'btn btn-red'; | |
| btn.disabled = false; | |
| arenaAnimating = true; | |
| arenaAbort = false; | |
| status.textContent = 'Fetching match data...'; | |
| status.style.color = 'var(--amber)'; | |
| try { | |
| const task = document.getElementById('arena-task').value; | |
| const seed = parseInt(document.getElementById('arena-seed').value) || 42; | |
| const res = await fetch(API + '/arena/api/auto', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ task_id: task, seed: seed }) | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || res.statusText); | |
| resetArenaUI(); | |
| status.textContent = `Animating match (${data.total_steps} steps)...`; | |
| await animateArenaMatch(data); | |
| if (!arenaAbort) { | |
| status.textContent = `Match complete \u2014 ${data.total_steps} steps`; | |
| status.style.color = 'var(--green)'; | |
| toast('Match completed successfully', 'success'); | |
| } else { | |
| status.textContent = 'Stopped'; | |
| status.style.color = 'var(--text-dim)'; | |
| } | |
| } catch (e) { | |
| status.textContent = 'Error: ' + e.message; | |
| status.style.color = 'var(--red)'; | |
| toast(e.message, 'error'); | |
| } finally { | |
| arenaAnimating = false; | |
| arenaAbort = false; | |
| btn.innerHTML = '▶ Run Auto Match'; | |
| btn.className = 'btn btn-primary'; | |
| } | |
| }; | |
| /* ═══════════════════ RESULTS TAB ═══════════════════ */ | |
| /* Live Demo */ | |
| let resultsDemoRunning = false; | |
| document.getElementById('btn-results-demo').onclick = async () => { | |
| if (resultsDemoRunning) return; | |
| const btn = document.getElementById('btn-results-demo'); | |
| btn.disabled = true; | |
| btn.textContent = 'Running...'; | |
| resultsDemoRunning = true; | |
| try { | |
| const res = await fetch(API + '/arena/api/auto', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ task_id: 'task_2', seed: 123 }) | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || res.statusText); | |
| const trace = data.trace; | |
| let cumF = 0, cumI = 0; | |
| const cF = [], cI = []; | |
| trace.forEach(t => { | |
| if (t.role === 'fraudster') cumF += t.reward; | |
| else if (t.role === 'investigator') cumI += t.reward; | |
| cF.push(cumF); | |
| cI.push(cumI); | |
| }); | |
| document.getElementById('results-legend').style.display = 'flex'; | |
| for (let i = 0; i < trace.length; i++) { | |
| renderMultiChart('results-chart', [ | |
| { data: cF.slice(0, i + 1), color: '#ef4444' }, | |
| { data: cI.slice(0, i + 1), color: '#6366f1' }, | |
| ], { height: 220, totalSteps: trace.length }); | |
| await sleep(600); | |
| } | |
| const fs = data.final_state; | |
| document.getElementById('results-summary').innerHTML = ` | |
| <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px"> | |
| <div class="stat-card"><div class="stat-label">Grader Score</div><div class="stat-value" style="color:var(--green)">${fs.grader_score != null ? fs.grader_score.toFixed(3) : '-'}</div></div> | |
| <div class="stat-card"><div class="stat-label">Fraudster Reward</div><div class="stat-value" style="color:var(--red)">${data.final_rewards.fraudster.toFixed(3)}</div></div> | |
| <div class="stat-card"><div class="stat-label">Investigator Reward</div><div class="stat-value" style="color:var(--indigo)">${data.final_rewards.investigator.toFixed(3)}</div></div> | |
| <div class="stat-card"><div class="stat-label">Total Steps</div><div class="stat-value">${data.total_steps}</div></div> | |
| <div class="stat-card"><div class="stat-label">End Reason</div><div class="stat-value" style="font-size:0.85rem;color:var(--text-dim)">${fs.end_reason || '-'}</div></div> | |
| </div>`; | |
| toast('Demo match rendered', 'success'); | |
| } catch (e) { | |
| toast(e.message, 'error'); | |
| } finally { | |
| resultsDemoRunning = false; | |
| btn.disabled = false; | |
| btn.textContent = 'Run Demo Match'; | |
| } | |
| }; | |
| /* | |
| * Model comparison data placeholder. | |
| * To add model curves, populate the `data` arrays with cumulative reward | |
| * values (one per match step) and set `ready: true`. | |
| */ | |
| const MODEL_CURVES = [ | |
| { name: 'Untrained Qwen3.5-0.8B', color: '#94a3b8', data: [], ready: false }, | |
| { name: 'Fine-tuned Qwen3.5-0.8B', color: '#22c55e', data: [], ready: false }, | |
| ]; | |
| document.getElementById('btn-model-compare').onclick = async () => { | |
| const ready = MODEL_CURVES.filter(m => m.ready && m.data.length); | |
| if (!ready.length) { toast('No model data available yet', 'info'); return; } | |
| const btn = document.getElementById('btn-model-compare'); | |
| btn.disabled = true; | |
| btn.textContent = 'Playing...'; | |
| document.getElementById('model-legend').style.display = 'flex'; | |
| const totalSteps = Math.max(...ready.map(m => m.data.length)); | |
| for (let i = 0; i < totalSteps; i++) { | |
| renderMultiChart('model-chart', | |
| ready.map(m => ({ data: m.data.slice(0, i + 1), color: m.color })), | |
| { height: 220, totalSteps: totalSteps } | |
| ); | |
| await sleep(600); | |
| } | |
| btn.disabled = false; | |
| btn.textContent = 'Play Comparison'; | |
| }; | |
| /* ═══════════════════ PLAYGROUND TAB ═══════════════════ */ | |
| const TARGETS = ['advertiser_history','landing_page','payment_method','targeting_overlap','campaign_structure','policy_classifier']; | |
| const TARGET_LABELS = { advertiser_history:'ADVERTISER', landing_page:'LANDING PAGE', payment_method:'PAYMENT', targeting_overlap:'TARGETING', campaign_structure:'CAMPAIGN', policy_classifier:'POLICY (LLAMA GUARD)' }; | |
| const FINDING_RE = /^\[(ad_\d+)\s*\/\s*([a-z_]+)\]/; | |
| let lastObs = null, pgVerdicts = {}, pgCumReward = 0, pgCumHistory = [], pgMaxBudget = 0, pgStep = 0, pgDone = false; | |
| let allParsedFindings = {}; | |
| function logLine(msg, cls) { | |
| const a = document.getElementById('log-area'); | |
| const d = document.createElement('div'); | |
| d.className = 'log-entry ' + (cls || ''); | |
| d.textContent = '[' + new Date().toLocaleTimeString() + '] ' + msg; | |
| a.appendChild(d); | |
| a.scrollTop = a.scrollHeight; | |
| } | |
| function parseFindings(raw) { | |
| const out = {}; | |
| if (!raw) return out; | |
| let curAd = null, curTgt = null, lines = []; | |
| raw.split('\n').forEach(line => { | |
| const m = line.trim().match(FINDING_RE); | |
| if (m) { | |
| if (curAd && curTgt) { if (!out[curAd]) out[curAd] = {}; out[curAd][curTgt] = lines.join('\n').trim(); } | |
| curAd = m[1]; curTgt = m[2]; lines = []; | |
| } else lines.push(line); | |
| }); | |
| if (curAd && curTgt) { if (!out[curAd]) out[curAd] = {}; out[curAd][curTgt] = lines.join('\n').trim(); } | |
| return out; | |
| } | |
| function focusedFromInfo(info) { const m = info && info.match(/Ad in Focus:\s*(ad_\d+)/); return m ? m[1] : null; } | |
| function renderPgStats(obs) { | |
| const qs = obs.queue_status || {}; | |
| document.getElementById('st-total').textContent = qs.total_ads ?? '-'; | |
| document.getElementById('st-reviewed').textContent = qs.reviewed ?? '-'; | |
| document.getElementById('st-budget').textContent = qs.investigation_budget ?? qs.steps_remaining ?? '-'; | |
| document.getElementById('st-step').textContent = pgMaxBudget ? (pgStep + ' / ' + pgMaxBudget) : String(pgStep); | |
| document.getElementById('st-score').textContent = '-'; | |
| const el = document.getElementById('st-cum'); | |
| el.textContent = (pgCumReward >= 0 ? '+' : '') + pgCumReward.toFixed(2); | |
| el.style.color = pgCumReward >= 0 ? 'var(--green)' : 'var(--red)'; | |
| } | |
| function renderPgChart() { | |
| if (!pgCumHistory.length) { | |
| document.getElementById('pg-chart').innerHTML = '<div style="color:var(--text-dim);font-size:0.75rem;text-align:center;padding:20px">No steps yet.</div>'; | |
| return; | |
| } | |
| const col = pgCumHistory[pgCumHistory.length - 1] >= 0 ? '#22c55e' : '#ef4444'; | |
| renderMultiChart('pg-chart', [{ data: pgCumHistory, color: col }], { height: 300 }); | |
| } | |
| function renderPgQueue(obs) { | |
| const ads = obs.available_ads || []; | |
| const focused = focusedFromInfo(obs.current_ad_info || ''); | |
| const ids = [...new Set([...ads, ...Object.keys(pgVerdicts)])].sort(); | |
| const el = document.getElementById('ad-queue'); | |
| el.innerHTML = ''; | |
| ids.forEach(id => { | |
| const d = document.createElement('div'); | |
| let cls = 'ad-chip'; | |
| if (id === focused) cls += ' focus'; | |
| else if (pgVerdicts[id]) cls += ' ' + (pgVerdicts[id].verdict || ''); | |
| d.className = cls; | |
| d.innerHTML = id + ' <span class="q-dot" style="background:' + (id === focused ? 'var(--amber)' : pgVerdicts[id] ? 'var(--green)' : 'var(--text-dim)') + '"></span>'; | |
| el.appendChild(d); | |
| }); | |
| if (!ids.length) el.innerHTML = '<span style="color:var(--text-dim)">Reset to load queue.</span>'; | |
| } | |
| function renderPgProfile(obs) { | |
| const info = obs.current_ad_info || ''; | |
| const body = document.getElementById('profile-body'); | |
| if (!info) { body.innerHTML = '<p style="color:var(--text-dim)">No ad in focus.</p>'; return; } | |
| const fid = focusedFromInfo(info); | |
| const cat = (info.match(/Category:\s*(.+)/) || [])[1] || ''; | |
| const copy = (info.match(/Ad copy:\s*(.+)/) || [])[1] || ''; | |
| body.innerHTML = | |
| '<div style="font-size:1.2rem;font-weight:800;margin-bottom:10px">' + (fid || '') + '</div>' + | |
| '<div style="font-size:0.68rem;color:var(--text-dim);margin-bottom:8px">Category: <strong style="color:var(--text)">' + esc(cat) + '</strong></div>' + | |
| (copy ? '<div class="ad-copy-block">' + esc(copy) + '</div>' : ''); | |
| } | |
| function mergeFindings(obs) { | |
| const raw = obs.investigation_findings || ''; | |
| const inv = parseFindings(raw); | |
| for (const adId in inv) { | |
| if (!allParsedFindings[adId]) allParsedFindings[adId] = {}; | |
| for (const target in inv[adId]) { | |
| allParsedFindings[adId][target] = inv[adId][target]; | |
| } | |
| } | |
| } | |
| function renderPgFindings(adId) { | |
| adId = adId || document.getElementById('act-ad').value || focusedFromInfo((lastObs && lastObs.current_ad_info) || ''); | |
| const adInv = (adId && allParsedFindings[adId]) || {}; | |
| const grid = document.getElementById('findings-grid'); | |
| grid.innerHTML = ''; | |
| TARGETS.forEach(t => { | |
| const card = document.createElement('div'); | |
| card.className = 'inv-card' + (adInv[t] ? ' revealed' : ' locked'); | |
| const label = TARGET_LABELS[t] || t; | |
| card.innerHTML = adInv[t] | |
| ? '<div class="inv-inner"><div class="inv-label">' + esc(label) + '</div><div class="inv-content">' + esc(adInv[t].slice(0, 180)) + (adInv[t].length > 180 ? '...' : '') + '</div></div>' | |
| : '<div class="inv-inner"><div class="inv-label">' + esc(label) + '</div><div class="inv-content">Classified</div></div><div class="lock-icon">🔒</div>'; | |
| grid.appendChild(card); | |
| }); | |
| } | |
| function fillAdSelects(obs) { | |
| const ads = obs.available_ads || []; | |
| ['act-ad','act-linked'].forEach(id => { | |
| const sel = document.getElementById(id); | |
| const prev = sel.value; | |
| sel.innerHTML = ''; | |
| ads.forEach(a => { const o = document.createElement('option'); o.value = a; o.textContent = a; sel.appendChild(o); }); | |
| if (prev && ads.includes(prev)) sel.value = prev; | |
| }); | |
| } | |
| function renderPgVerdicts() { | |
| const el = document.getElementById('verdict-list'); | |
| const keys = Object.keys(pgVerdicts); | |
| if (!keys.length) { el.innerHTML = '<p style="color:var(--text-dim);font-size:0.75rem;">None yet.</p>'; return; } | |
| el.innerHTML = ''; | |
| keys.forEach(aid => { | |
| const v = pgVerdicts[aid]; | |
| const row = document.createElement('div'); | |
| row.className = 'verdict-row'; | |
| row.innerHTML = '<span>' + esc(aid) + '</span><span style="color:var(--text-dim)">' + ((v.confidence * 100) | 0) + '%</span><span class="v-badge ' + esc(v.verdict) + '">' + esc(v.verdict) + '</span>'; | |
| el.appendChild(row); | |
| }); | |
| } | |
| function applyPgObs(data) { | |
| const obs = data.observation || {}; | |
| lastObs = obs; | |
| mergeFindings(obs); | |
| renderPgStats(obs); renderPgQueue(obs); renderPgProfile(obs); | |
| renderPgFindings(); fillAdSelects(obs); renderPgChart(); renderPgVerdicts(); | |
| } | |
| function toggleActionFields() { | |
| const t = document.getElementById('act-type').value; | |
| document.getElementById('grp-target').classList.toggle('hidden', t !== 'investigate'); | |
| document.getElementById('grp-verdict').classList.toggle('hidden', t !== 'verdict'); | |
| document.getElementById('grp-conf').classList.toggle('hidden', t !== 'verdict'); | |
| document.getElementById('grp-link').classList.toggle('hidden', t !== 'link_accounts'); | |
| document.getElementById('grp-reason').classList.toggle('hidden', t !== 'link_accounts'); | |
| } | |
| document.getElementById('act-type').addEventListener('change', toggleActionFields); | |
| document.getElementById('act-ad').addEventListener('change', () => { renderPgFindings(); }); | |
| document.getElementById('btn-reset').onclick = async () => { | |
| try { | |
| const task = document.getElementById('task-select').value; | |
| const res = await fetch(API + '/investigate/api/reset', { | |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ task_id: task, seed: 42 }) | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || res.statusText); | |
| pgVerdicts = {}; pgCumReward = 0; pgCumHistory = []; pgStep = 0; pgDone = false; | |
| allParsedFindings = {}; | |
| pgMaxBudget = (data.observation && data.observation.queue_status && data.observation.queue_status.investigation_budget) || 25; | |
| applyPgObs(data); | |
| document.getElementById('btn-step').disabled = false; | |
| document.getElementById('feedback').textContent = 'Episode started. Budget: ' + pgMaxBudget + ' actions.'; | |
| logLine('Reset OK (' + task + ')', 'ok'); | |
| toast('Environment reset', 'success'); | |
| } catch (e) { toast(e.message, 'error'); logLine('Reset failed: ' + e.message, 'bad'); } | |
| }; | |
| document.getElementById('btn-step').onclick = async () => { | |
| if (pgDone) { toast('Episode finished \u2014 reset first', 'error'); return; } | |
| const t = document.getElementById('act-type').value; | |
| const ad = document.getElementById('act-ad').value; | |
| const body = { action_type: t, ad_id: ad }; | |
| if (t === 'investigate') body.investigation_target = document.getElementById('act-target').value; | |
| else if (t === 'verdict') { | |
| body.verdict = document.getElementById('act-verdict').value; | |
| body.confidence = parseFloat(document.getElementById('act-conf').value) || 0.5; | |
| pgVerdicts[ad] = { verdict: body.verdict, confidence: body.confidence }; | |
| } else if (t === 'link_accounts') { | |
| body.linked_ad_id = document.getElementById('act-linked').value; | |
| body.link_reason = document.getElementById('act-reason').value.trim() || '\u2014'; | |
| } | |
| try { | |
| const res = await fetch(API + '/investigate/api/step', { | |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body) | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)); | |
| const r = data.reward != null ? data.reward : 0; | |
| pgCumReward += r; pgCumHistory.push(pgCumReward); pgStep += 1; | |
| pgDone = !!data.done; | |
| applyPgObs(data); | |
| document.getElementById('feedback').textContent = (data.observation && data.observation.feedback) || ('Reward ' + r); | |
| logLine('Step ' + pgStep + ' reward ' + r + ' cum ' + pgCumReward.toFixed(2), r < 0 ? 'bad' : 'ok'); | |
| if (data.done) { document.getElementById('btn-step').disabled = true; toast('Episode complete', 'success'); } | |
| } catch (e) { toast(e.message, 'error'); logLine('Step error: ' + e.message, 'bad'); } | |
| }; | |
| document.getElementById('btn-score').onclick = async () => { | |
| try { | |
| const res = await fetch(API + '/grader'); | |
| const g = await res.json(); | |
| if (g.grader_score != null) { | |
| document.getElementById('st-score').textContent = Number(g.grader_score).toFixed(3); | |
| toast('Grader score: ' + g.grader_score.toFixed(3), 'success'); | |
| } else toast(g.error || 'No grader yet', 'info'); | |
| } catch (e) { toast(e.message, 'error'); } | |
| }; | |
| document.getElementById('btn-clear-log').onclick = () => { document.getElementById('log-area').innerHTML = ''; }; | |
| toggleActionFields(); | |
| </script> | |
| </body> | |
| </html> | |