feat(benchmark): Add Builder Tag system for community leaderboard

- Add builder_tag column to benchmark_results table
- Create BuilderTagSelector component with word dropdowns + randomize
- Add 50 adjectives and 50 nouns for NOMAD-themed tags (e.g., Tactical-Llama-1234)
- Add anonymous sharing option checkbox
- Add builder tag display in Benchmark Details section
- Add Benchmark History section showing all past benchmarks
- Update submission API to accept anonymous flag
- Add /api/benchmark/builder-tag endpoint to update tags

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-01-24 23:04:58 -08:00
parent 82719188cd
commit 4d34b9a2f3
9 changed files with 512 additions and 8 deletions

View File

@ -169,9 +169,10 @@ export default class BenchmarkController {
*/
async submit({ request, response }: HttpContext) {
const payload = await request.validateUsing(submitBenchmarkValidator)
const anonymous = request.input('anonymous') === true || request.input('anonymous') === 'true'
try {
const submitResult = await this.benchmarkService.submitToRepository(payload.benchmark_id)
const submitResult = await this.benchmarkService.submitToRepository(payload.benchmark_id, anonymous)
return response.send({
success: true,
repository_id: submitResult.repository_id,
@ -185,6 +186,48 @@ export default class BenchmarkController {
}
}
/**
* Update builder tag for a benchmark result
*/
async updateBuilderTag({ request, response }: HttpContext) {
const benchmarkId = request.input('benchmark_id')
const builderTag = request.input('builder_tag')
if (!benchmarkId) {
return response.status(400).send({
success: false,
error: 'benchmark_id is required',
})
}
const result = await this.benchmarkService.getResultById(benchmarkId)
if (!result) {
return response.status(404).send({
success: false,
error: 'Benchmark result not found',
})
}
// Validate builder tag format if provided
if (builderTag) {
const tagPattern = /^[A-Za-z]+-[A-Za-z]+-\d{4}$/
if (!tagPattern.test(builderTag)) {
return response.status(400).send({
success: false,
error: 'Invalid builder tag format. Expected: Word-Word-0000',
})
}
}
result.builder_tag = builderTag || null
await result.save()
return response.send({
success: true,
builder_tag: result.builder_tag,
})
}
/**
* Get comparison stats from central repository
*/

View File

@ -74,6 +74,9 @@ export default class BenchmarkResult extends BaseModel {
@column()
declare repository_id: string | null
@column()
declare builder_tag: string | null
@column.dateTime({ autoCreate: true })
declare created_at: DateTime

View File

@ -107,7 +107,7 @@ export class BenchmarkService {
/**
* Submit benchmark results to central repository
*/
async submitToRepository(benchmarkId?: string): Promise<RepositorySubmitResponse> {
async submitToRepository(benchmarkId?: string, anonymous?: boolean): Promise<RepositorySubmitResponse> {
const result = benchmarkId
? await this.getResultById(benchmarkId)
: await this.getLatestResult()
@ -145,6 +145,7 @@ export class BenchmarkService {
nomad_score: result.nomad_score,
nomad_version: SystemService.getAppVersion(),
benchmark_version: '1.0.0',
builder_tag: anonymous ? null : result.builder_tag,
}
try {

View File

@ -0,0 +1,17 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'benchmark_results'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.string('builder_tag', 64).nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('builder_tag')
})
}
}

View File

@ -0,0 +1,131 @@
import { useState, useEffect } from 'react'
import { ArrowPathIcon } from '@heroicons/react/24/outline'
import {
ADJECTIVES,
NOUNS,
generateRandomNumber,
generateRandomBuilderTag,
parseBuilderTag,
buildBuilderTag,
} from '~/lib/builderTagWords'
interface BuilderTagSelectorProps {
value: string | null
onChange: (tag: string) => void
disabled?: boolean
}
export default function BuilderTagSelector({
value,
onChange,
disabled = false,
}: BuilderTagSelectorProps) {
const [adjective, setAdjective] = useState<string>(ADJECTIVES[0])
const [noun, setNoun] = useState<string>(NOUNS[0])
const [number, setNumber] = useState<string>(generateRandomNumber())
// Parse existing value on mount
useEffect(() => {
if (value) {
const parsed = parseBuilderTag(value)
if (parsed) {
setAdjective(parsed.adjective)
setNoun(parsed.noun)
setNumber(parsed.number)
}
} else {
// Generate a random tag for new users
const randomTag = generateRandomBuilderTag()
const parsed = parseBuilderTag(randomTag)
if (parsed) {
setAdjective(parsed.adjective)
setNoun(parsed.noun)
setNumber(parsed.number)
onChange(randomTag)
}
}
}, [])
// Update parent when selections change
const updateTag = (newAdjective: string, newNoun: string, newNumber: string) => {
const tag = buildBuilderTag(newAdjective, newNoun, newNumber)
onChange(tag)
}
const handleAdjectiveChange = (newAdjective: string) => {
setAdjective(newAdjective)
updateTag(newAdjective, noun, number)
}
const handleNounChange = (newNoun: string) => {
setNoun(newNoun)
updateTag(adjective, newNoun, number)
}
const handleRandomize = () => {
const newAdjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
const newNoun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
const newNumber = generateRandomNumber()
setAdjective(newAdjective)
setNoun(newNoun)
setNumber(newNumber)
updateTag(newAdjective, newNoun, newNumber)
}
const currentTag = buildBuilderTag(adjective, noun, number)
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<select
value={adjective}
onChange={(e) => handleAdjectiveChange(e.target.value)}
disabled={disabled}
className="px-3 py-2 bg-desert-stone-lighter border border-desert-stone-light rounded-lg text-desert-green font-medium focus:outline-none focus:ring-2 focus:ring-desert-green disabled:opacity-50"
>
{ADJECTIVES.map((adj) => (
<option key={adj} value={adj}>
{adj}
</option>
))}
</select>
<span className="text-desert-stone-dark font-bold">-</span>
<select
value={noun}
onChange={(e) => handleNounChange(e.target.value)}
disabled={disabled}
className="px-3 py-2 bg-desert-stone-lighter border border-desert-stone-light rounded-lg text-desert-green font-medium focus:outline-none focus:ring-2 focus:ring-desert-green disabled:opacity-50"
>
{NOUNS.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<span className="text-desert-stone-dark font-bold">-</span>
<span className="px-3 py-2 bg-desert-stone-lighter border border-desert-stone-light rounded-lg text-desert-green font-mono font-bold">
{number}
</span>
<button
type="button"
onClick={handleRandomize}
disabled={disabled}
className="p-2 text-desert-stone-dark hover:text-desert-green hover:bg-desert-stone-lighter rounded-lg transition-colors disabled:opacity-50"
title="Randomize"
>
<ArrowPathIcon className="w-5 h-5" />
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-desert-stone-dark">Your Builder Tag:</span>
<span className="font-mono font-bold text-desert-green">{currentTag}</span>
</div>
</div>
)
}

View File

@ -0,0 +1,145 @@
// Builder Tag word lists for generating unique, NOMAD-themed identifiers
// Format: [Adjective]-[Noun]-[4-digit number]
export const ADJECTIVES = [
'Tactical',
'Stealth',
'Rogue',
'Shadow',
'Ghost',
'Silent',
'Covert',
'Lone',
'Nomad',
'Digital',
'Cyber',
'Off-Grid',
'Remote',
'Arctic',
'Desert',
'Mountain',
'Urban',
'Bunker',
'Hidden',
'Secure',
'Armored',
'Fortified',
'Mobile',
'Solar',
'Nuclear',
'Storm',
'Thunder',
'Iron',
'Steel',
'Titanium',
'Carbon',
'Quantum',
'Neural',
'Alpha',
'Omega',
'Delta',
'Sigma',
'Apex',
'Prime',
'Elite',
'Midnight',
'Dawn',
'Dusk',
'Feral',
'Relic',
'Analog',
'Hardened',
'Vigilant',
'Outland',
'Frontier',
] as const
export const NOUNS = [
'Llama',
'Wolf',
'Bear',
'Eagle',
'Falcon',
'Hawk',
'Raven',
'Fox',
'Coyote',
'Panther',
'Cobra',
'Viper',
'Phoenix',
'Dragon',
'Sentinel',
'Guardian',
'Ranger',
'Scout',
'Survivor',
'Prepper',
'Nomad',
'Wanderer',
'Drifter',
'Outpost',
'Shelter',
'Bunker',
'Vault',
'Cache',
'Haven',
'Fortress',
'Citadel',
'Node',
'Hub',
'Grid',
'Network',
'Signal',
'Beacon',
'Tower',
'Server',
'Cluster',
'Array',
'Matrix',
'Core',
'Nexus',
'Archive',
'Relay',
'Silo',
'Depot',
'Bastion',
'Homestead',
] as const
export type Adjective = (typeof ADJECTIVES)[number]
export type Noun = (typeof NOUNS)[number]
export function generateRandomNumber(): string {
return String(Math.floor(Math.random() * 10000)).padStart(4, '0')
}
export function generateRandomBuilderTag(): string {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
const number = generateRandomNumber()
return `${adjective}-${noun}-${number}`
}
export function parseBuilderTag(tag: string): {
adjective: Adjective
noun: Noun
number: string
} | null {
const match = tag.match(/^(.+)-(.+)-(\d{4})$/)
if (!match) return null
const [, adjective, noun, number] = match
if (!ADJECTIVES.includes(adjective as Adjective)) return null
if (!NOUNS.includes(noun as Noun)) return null
return {
adjective: adjective as Adjective,
noun: noun as Noun,
number,
}
}
export function buildBuilderTag(adjective: string, noun: string, number: string): string {
return `${adjective}-${noun}-${number}`
}

View File

@ -1,18 +1,20 @@
import { Head, Link } from '@inertiajs/react'
import { useState, useEffect } from 'react'
import SettingsLayout from '~/layouts/SettingsLayout'
import { useQuery, useMutation } from '@tanstack/react-query'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import CircularGauge from '~/components/systeminfo/CircularGauge'
import InfoCard from '~/components/systeminfo/InfoCard'
import Alert from '~/components/Alert'
import StyledButton from '~/components/StyledButton'
import InfoTooltip from '~/components/InfoTooltip'
import BuilderTagSelector from '~/components/BuilderTagSelector'
import {
ChartBarIcon,
CpuChipIcon,
CircleStackIcon,
ServerIcon,
ChevronDownIcon,
ClockIcon,
} from '@heroicons/react/24/outline'
import { IconRobot } from '@tabler/icons-react'
import { useTransmit } from 'react-adonis-transmit'
@ -29,10 +31,16 @@ export default function BenchmarkPage(props: {
}
}) {
const { subscribe } = useTransmit()
const queryClient = useQueryClient()
const [progress, setProgress] = useState<BenchmarkProgressWithID | null>(null)
const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')
const [showDetails, setShowDetails] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showAIRequiredAlert, setShowAIRequiredAlert] = useState(false)
const [shareAnonymously, setShareAnonymously] = useState(false)
const [currentBuilderTag, setCurrentBuilderTag] = useState<string | null>(
props.benchmark.latestResult?.builder_tag || null
)
// Check if AI Assistant is installed
const { data: aiInstalled } = useQuery({
@ -61,6 +69,16 @@ export default function BenchmarkPage(props: {
initialData: props.benchmark.latestResult,
})
// Fetch all benchmark results for history
const { data: benchmarkHistory } = useQuery({
queryKey: ['benchmark', 'history'],
queryFn: async () => {
const res = await fetch('/api/benchmark/results')
const data = await res.json()
return data.results as BenchmarkResult[]
},
})
// Run benchmark mutation (uses sync mode by default for simpler local dev)
const runBenchmark = useMutation({
mutationFn: async (type: 'full' | 'system' | 'ai') => {
@ -118,15 +136,45 @@ export default function BenchmarkPage(props: {
},
})
// Update builder tag mutation
const updateBuilderTag = useMutation({
mutationFn: async ({ benchmarkId, builderTag }: { benchmarkId: string; builderTag: string }) => {
const res = await fetch('/api/benchmark/builder-tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ benchmark_id: benchmarkId, builder_tag: builderTag }),
})
const data = await res.json()
if (!data.success) {
throw new Error(data.error || 'Failed to update builder tag')
}
return data
},
onSuccess: () => {
refetchLatest()
queryClient.invalidateQueries({ queryKey: ['benchmark', 'history'] })
},
})
// Submit to repository mutation
const [submitError, setSubmitError] = useState<string | null>(null)
const submitResult = useMutation({
mutationFn: async (benchmarkId?: string) => {
mutationFn: async ({ benchmarkId, anonymous }: { benchmarkId: string; anonymous: boolean }) => {
setSubmitError(null)
// First, save the current builder tag to the benchmark
if (currentBuilderTag && !anonymous) {
await fetch('/api/benchmark/builder-tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ benchmark_id: benchmarkId, builder_tag: currentBuilderTag }),
})
}
const res = await fetch('/api/benchmark/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ benchmark_id: benchmarkId }),
body: JSON.stringify({ benchmark_id: benchmarkId, anonymous }),
})
const data = await res.json()
if (!data.success) {
@ -136,6 +184,7 @@ export default function BenchmarkPage(props: {
},
onSuccess: () => {
refetchLatest()
queryClient.invalidateQueries({ queryKey: ['benchmark', 'history'] })
},
onError: (error: Error) => {
setSubmitError(error.message)
@ -395,12 +444,43 @@ export default function BenchmarkPage(props: {
{/* Share with Community - Only for full benchmarks with AI data */}
{canShareBenchmark && (
<div className="space-y-3">
<div className="space-y-4 mt-6 pt-6 border-t border-desert-stone-light">
<h3 className="font-semibold text-desert-green">Share with Community</h3>
<p className="text-sm text-desert-stone-dark">
Share your benchmark score anonymously with the NOMAD community. Only your hardware specs and scores are sent no identifying information.
Share your benchmark on the community leaderboard. Choose a Builder Tag to claim your spot, or share anonymously.
</p>
{/* Builder Tag Selector */}
<div className="space-y-2">
<label className="block text-sm font-medium text-desert-stone-dark">
Your Builder Tag
</label>
<BuilderTagSelector
value={currentBuilderTag}
onChange={setCurrentBuilderTag}
disabled={shareAnonymously || submitResult.isPending}
/>
</div>
{/* Anonymous checkbox */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={shareAnonymously}
onChange={(e) => setShareAnonymously(e.target.checked)}
disabled={submitResult.isPending}
className="w-4 h-4 rounded border-desert-stone-light text-desert-green focus:ring-desert-green"
/>
<span className="text-sm text-desert-stone-dark">
Share anonymously (no Builder Tag shown on leaderboard)
</span>
</label>
<StyledButton
onClick={() => submitResult.mutate(latestResult.benchmark_id)}
onClick={() => submitResult.mutate({
benchmarkId: latestResult.benchmark_id,
anonymous: shareAnonymously
})}
disabled={submitResult.isPending}
icon='CloudArrowUpIcon'
>
@ -677,6 +757,10 @@ export default function BenchmarkPage(props: {
<span className="text-desert-stone-dark">Run Date</span>
<span>{new Date(latestResult.created_at as unknown as string).toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span className="text-desert-stone-dark">Builder Tag</span>
<span className="font-mono">{latestResult.builder_tag || 'Not set'}</span>
</div>
{latestResult.ai_model_used && (
<div className="flex justify-between">
<span className="text-desert-stone-dark">AI Model Used</span>
@ -700,6 +784,83 @@ export default function BenchmarkPage(props: {
)}
</div>
</section>
{/* Benchmark History */}
{benchmarkHistory && benchmarkHistory.length > 1 && (
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
Benchmark History
</h2>
<div className="bg-desert-white rounded-lg border border-desert-stone-light shadow-sm overflow-hidden">
<button
onClick={() => setShowHistory(!showHistory)}
className="w-full p-4 flex items-center justify-between hover:bg-desert-stone-lighter/30 transition-colors"
>
<div className="flex items-center gap-2">
<ClockIcon className="w-5 h-5 text-desert-stone-dark" />
<span className="font-medium text-desert-green">
{benchmarkHistory.length} benchmark{benchmarkHistory.length !== 1 ? 's' : ''} recorded
</span>
</div>
<ChevronDownIcon
className={`w-5 h-5 text-desert-stone-dark transition-transform ${showHistory ? 'rotate-180' : ''}`}
/>
</button>
{showHistory && (
<div className="border-t border-desert-stone-light">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-desert-stone-lighter/50">
<tr>
<th className="text-left p-3 font-medium text-desert-stone-dark">Date</th>
<th className="text-left p-3 font-medium text-desert-stone-dark">Type</th>
<th className="text-left p-3 font-medium text-desert-stone-dark">Score</th>
<th className="text-left p-3 font-medium text-desert-stone-dark">Builder Tag</th>
<th className="text-left p-3 font-medium text-desert-stone-dark">Shared</th>
</tr>
</thead>
<tbody className="divide-y divide-desert-stone-lighter">
{benchmarkHistory.map((result) => (
<tr
key={result.benchmark_id}
className={`hover:bg-desert-stone-lighter/30 ${
result.benchmark_id === latestResult?.benchmark_id
? 'bg-desert-green/5'
: ''
}`}
>
<td className="p-3">
{new Date(result.created_at as unknown as string).toLocaleDateString()}
</td>
<td className="p-3 capitalize">{result.benchmark_type}</td>
<td className="p-3">
<span className="font-bold text-desert-green">
{result.nomad_score.toFixed(1)}
</span>
</td>
<td className="p-3 font-mono text-xs">
{result.builder_tag || '—'}
</td>
<td className="p-3">
{result.submitted_to_repository ? (
<span className="text-green-600"></span>
) : (
<span className="text-desert-stone-dark"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</section>
)}
</>
)}

View File

@ -134,6 +134,7 @@ router
router.get('/results/latest', [BenchmarkController, 'latest'])
router.get('/results/:id', [BenchmarkController, 'show'])
router.post('/submit', [BenchmarkController, 'submit'])
router.post('/builder-tag', [BenchmarkController, 'updateBuilderTag'])
router.get('/comparison', [BenchmarkController, 'comparison'])
router.get('/status', [BenchmarkController, 'status'])
router.get('/settings', [BenchmarkController, 'settings'])

View File

@ -46,6 +46,7 @@ export type BenchmarkResultSlim = Pick<
| 'nomad_score'
| 'submitted_to_repository'
| 'created_at'
| 'builder_tag'
> & {
cpu_model: string
gpu_model: string | null
@ -113,6 +114,7 @@ export type RepositorySubmission = Pick<
nomad_version: string
benchmark_version: string
ram_gb: number
builder_tag: string | null // null = anonymous submission
}
// Central repository response types