From 4d34b9a2f381347bad9cc71abd2ee8ad94d0f3e9 Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Sat, 24 Jan 2026 23:04:58 -0800 Subject: [PATCH] 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 --- admin/app/controllers/benchmark_controller.ts | 45 ++++- admin/app/models/benchmark_result.ts | 3 + admin/app/services/benchmark_service.ts | 3 +- ...48_add_builder_tag_to_benchmark_results.ts | 17 ++ .../inertia/components/BuilderTagSelector.tsx | 131 +++++++++++++ admin/inertia/lib/builderTagWords.ts | 145 +++++++++++++++ admin/inertia/pages/settings/benchmark.tsx | 173 +++++++++++++++++- admin/start/routes.ts | 1 + admin/types/benchmark.ts | 2 + 9 files changed, 512 insertions(+), 8 deletions(-) create mode 100644 admin/database/migrations/1769324448_add_builder_tag_to_benchmark_results.ts create mode 100644 admin/inertia/components/BuilderTagSelector.tsx create mode 100644 admin/inertia/lib/builderTagWords.ts diff --git a/admin/app/controllers/benchmark_controller.ts b/admin/app/controllers/benchmark_controller.ts index 19c5b94..58eaf18 100644 --- a/admin/app/controllers/benchmark_controller.ts +++ b/admin/app/controllers/benchmark_controller.ts @@ -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 */ diff --git a/admin/app/models/benchmark_result.ts b/admin/app/models/benchmark_result.ts index 8aaa8ae..fdede41 100644 --- a/admin/app/models/benchmark_result.ts +++ b/admin/app/models/benchmark_result.ts @@ -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 diff --git a/admin/app/services/benchmark_service.ts b/admin/app/services/benchmark_service.ts index f19672d..f262d1f 100644 --- a/admin/app/services/benchmark_service.ts +++ b/admin/app/services/benchmark_service.ts @@ -107,7 +107,7 @@ export class BenchmarkService { /** * Submit benchmark results to central repository */ - async submitToRepository(benchmarkId?: string): Promise { + async submitToRepository(benchmarkId?: string, anonymous?: boolean): Promise { 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 { diff --git a/admin/database/migrations/1769324448_add_builder_tag_to_benchmark_results.ts b/admin/database/migrations/1769324448_add_builder_tag_to_benchmark_results.ts new file mode 100644 index 0000000..49555a1 --- /dev/null +++ b/admin/database/migrations/1769324448_add_builder_tag_to_benchmark_results.ts @@ -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') + }) + } +} diff --git a/admin/inertia/components/BuilderTagSelector.tsx b/admin/inertia/components/BuilderTagSelector.tsx new file mode 100644 index 0000000..a7b1ff8 --- /dev/null +++ b/admin/inertia/components/BuilderTagSelector.tsx @@ -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(ADJECTIVES[0]) + const [noun, setNoun] = useState(NOUNS[0]) + const [number, setNumber] = useState(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 ( +
+
+ + + - + + + + - + + + {number} + + + +
+ +
+ Your Builder Tag: + {currentTag} +
+
+ ) +} diff --git a/admin/inertia/lib/builderTagWords.ts b/admin/inertia/lib/builderTagWords.ts new file mode 100644 index 0000000..50ddf91 --- /dev/null +++ b/admin/inertia/lib/builderTagWords.ts @@ -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}` +} diff --git a/admin/inertia/pages/settings/benchmark.tsx b/admin/inertia/pages/settings/benchmark.tsx index 040b5f5..e58aec0 100644 --- a/admin/inertia/pages/settings/benchmark.tsx +++ b/admin/inertia/pages/settings/benchmark.tsx @@ -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(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( + 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(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 && ( -
+
+

Share with Community

- 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.

+ + {/* Builder Tag Selector */} +
+ + +
+ + {/* Anonymous checkbox */} + + 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: { Run Date {new Date(latestResult.created_at as unknown as string).toLocaleString()}
+
+ Builder Tag + {latestResult.builder_tag || 'Not set'} +
{latestResult.ai_model_used && (
AI Model Used @@ -700,6 +784,83 @@ export default function BenchmarkPage(props: { )}
+ + {/* Benchmark History */} + {benchmarkHistory && benchmarkHistory.length > 1 && ( +
+

+
+ Benchmark History +

+ +
+ + + {showHistory && ( +
+
+ + + + + + + + + + + + {benchmarkHistory.map((result) => ( + + + + + + + + ))} + +
DateTypeScoreBuilder TagShared
+ {new Date(result.created_at as unknown as string).toLocaleDateString()} + {result.benchmark_type} + + {result.nomad_score.toFixed(1)} + + + {result.builder_tag || '—'} + + {result.submitted_to_repository ? ( + + ) : ( + + )} +
+
+
+ )} +
+
+ )} )} diff --git a/admin/start/routes.ts b/admin/start/routes.ts index f3f4e43..87401d4 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -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']) diff --git a/admin/types/benchmark.ts b/admin/types/benchmark.ts index 35f406e..91e4db8 100644 --- a/admin/types/benchmark.ts +++ b/admin/types/benchmark.ts @@ -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