Phase 2: benchmark_runs + run_results schema, models with visibility scope and immutability guard, factories, tests

This commit is contained in:
PeterChrz 2026-09-16 22:34:43 -04:00
parent 6d5f9d1ac3
commit 49033c2456
Signed by untrusted user who does not match committer: pch
GPG key ID: 8F0826ECF7302C63
19 changed files with 1593 additions and 342 deletions

View file

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
@ -19,7 +20,7 @@ public function __invoke(EmailVerificationRequest $request): RedirectResponse
}
if ($request->user()->markEmailAsVerified()) {
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
/** @var MustVerifyEmail $user */
$user = $request->user();
event(new Verified($user));

View file

@ -0,0 +1,97 @@
<?php
namespace App\Models;
use Database\Factories\BenchmarkRunFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BenchmarkRun extends Model
{
/** @use HasFactory<BenchmarkRunFactory> */
use HasFactory;
public const VISIBILITY_PUBLIC = 'public';
public const VISIBILITY_PRIVATE = 'private';
/**
* Columns written only at submission/import time. Everything here is
* immutable once stored; user updates may only touch $overlay.
*/
protected $fillable = [
'run_id', 'ran_at', 'suite', 'run_key',
'model_id', 'model_quant', 'model_quant_raw', 'model_path',
'engine', 'engine_build', 'n_ctx', 'slots',
'gpu', 'gpu_count', 'gpu_source', 'host', 'client', 'endpoint',
'label', 'config', 'image_digest', 'validators', 'suite_versions',
'harness', 'harness_ver', 'harness_mode', 'user_id',
'visibility', 'notes',
];
/** The only fields a user may change after submission. */
public const OVERLAY_FIELDS = ['visibility', 'notes'];
protected function casts(): array
{
return [
'ran_at' => 'datetime',
'validators' => 'array',
'suite_versions' => 'array',
'n_ctx' => 'integer',
'slots' => 'integer',
'gpu_count' => 'integer',
];
}
/**
* Submitted metrics are immutable: any update that touches a column
* outside OVERLAY_FIELDS is silently reverted before it hits the
* database. This is the safety net for every user-facing update path.
*/
protected static function booted(): void
{
static::updating(function (self $run): void {
foreach ($run->getDirty() as $key => $_) {
if (! in_array($key, self::OVERLAY_FIELDS, true)) {
$run->setAttribute($key, $run->getOriginal($key));
}
}
});
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function results(): HasMany
{
return $this->hasMany(RunResult::class);
}
public function isOwnedBy(?User $user): bool
{
return $user !== null && $this->user_id === $user->id;
}
/**
* Runs visible to $user: everything public, plus the user's own private
* runs. Applied as a local scope so every listing query starts from it.
*/
public function scopeVisibleTo(Builder $query, ?User $user): Builder
{
return $query->where(function (Builder $q) use ($user) {
$q->where('visibility', self::VISIBILITY_PUBLIC);
if ($user !== null) {
$q->orWhere(fn (Builder $own) => $own
->where('visibility', self::VISIBILITY_PRIVATE)
->where('user_id', $user->id));
}
});
}
}

45
app/Models/RunResult.php Normal file
View file

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Database\Factories\RunResultFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RunResult extends Model
{
/** @use HasFactory<RunResultFactory> */
use HasFactory;
protected $fillable = [
'benchmark_run_id', 'suite', 'section', 'task', 'level',
'ok', 'decode', 'user_tps', 'ttft', 't_answer', 'wall',
'reasoning', 'visible', 'total', 'tool_calls',
'checks_passed', 'checks_total', 'finish', 'detail', 'payload',
];
protected function casts(): array
{
return [
'ok' => 'boolean',
'decode' => 'float',
'user_tps' => 'float',
'ttft' => 'float',
't_answer' => 'float',
'wall' => 'float',
'reasoning' => 'integer',
'visible' => 'integer',
'total' => 'integer',
'tool_calls' => 'integer',
'checks_passed' => 'integer',
'checks_total' => 'integer',
'payload' => 'array',
];
}
public function run(): BelongsTo
{
return $this->belongsTo(BenchmarkRun::class, 'benchmark_run_id');
}
}

View file

@ -3,6 +3,7 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@ -10,7 +11,7 @@
class User extends Authenticatable // implements MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**

View file

@ -2,8 +2,10 @@
namespace App\Providers;
use App\Models\User;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Fortify;
@ -20,9 +22,9 @@ public function boot(): void
Fortify::ignoreRoutes();
Fortify::authenticateUsing(function (Request $request) {
$user = \App\Models\User::where('email', $request->email)->first();
$user = User::where('email', $request->email)->first();
if ($user && \Illuminate\Support\Facades\Hash::check($request->password, $user->password)) {
if ($user && Hash::check($request->password, $user->password)) {
return $user;
}

View file

@ -1,7 +1,11 @@
<?php
use App\Providers\AppServiceProvider;
use App\Providers\FortifyServiceProvider;
use App\Providers\VoltServiceProvider;
return [
App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
App\Providers\VoltServiceProvider::class,
AppServiceProvider::class,
FortifyServiceProvider::class,
VoltServiceProvider::class,
];

View file

@ -25,7 +25,8 @@
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3"
"pestphp/pest": "^4.1",
"pestphp/pest-plugin-laravel": "^4.0"
},
"autoload": {
"psr-4": {

1458
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,7 @@
<?php
use App\Models\User;
return [
/*
@ -62,7 +64,7 @@
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [

View file

@ -57,9 +57,9 @@
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
'options' => extension_loaded('pdo_mysql') && env('MYSQL_ATTR_SSL_CA') ? [
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
] : [],
],
'mariadb' => [
@ -77,9 +77,9 @@
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
'options' => extension_loaded('pdo_mysql') && env('MYSQL_ATTR_SSL_CA') ? [
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
] : [],
],
'pgsql' => [

View file

@ -0,0 +1,54 @@
<?php
namespace Database\Factories;
use App\Models\BenchmarkRun;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<BenchmarkRun> */
class BenchmarkRunFactory extends Factory
{
public function definition(): array
{
$quant = $this->faker->randomElement(['Q4_K_M', 'Q5_K_M', 'Q6_K', 'Q8_0', 'fp8', 'none']);
return [
'run_id' => str()->random(12),
'ran_at' => $this->faker->dateTimeThisMonth(),
'suite' => $this->faker->randomElement(['softbench', 'infrabench', 'prosebench', 'psychbench']),
'run_key' => null,
'model_id' => $this->faker->randomElement([
'Qwen/Qwen3-8B', 'bartowski/Qwen3.8-27B-GGUF', 'meta-llama/Llama-3.3-70B',
]),
'model_quant' => $quant === 'none' ? null : $quant,
'model_quant_raw' => $quant === 'none' ? null : $quant,
'model_path' => null,
'engine' => $this->faker->randomElement(['llama.cpp', 'vllm', 'unknown']),
'engine_build' => 'b10711-9723942ad',
'n_ctx' => $this->faker->randomElement([32768, 65536, 131072]),
'slots' => 1,
'gpu' => $this->faker->randomElement(['unknown', 'RTX 4090', 'RX 7900 XTX']),
'gpu_count' => $this->faker->numberBetween(0, 2),
'gpu_source' => 'remote-endpoint',
'host' => null,
'client' => null,
'endpoint' => null,
'label' => $this->faker->optional()->word(),
'config' => null,
'image_digest' => 'quay.io/pch/festbench@sha256:'.str()->random(64),
'validators' => null,
'suite_versions' => null,
'harness' => 'direct',
'harness_ver' => null,
'harness_mode' => null,
'user_id' => null,
'visibility' => BenchmarkRun::VISIBILITY_PUBLIC,
'notes' => null,
];
}
public function private(): static
{
return $this->state(fn () => ['visibility' => BenchmarkRun::VISIBILITY_PRIVATE]);
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace Database\Factories;
use App\Models\BenchmarkRun;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<RunResult> */
class RunResultFactory extends Factory
{
public function definition(): array
{
$visible = $this->faker->numberBetween(20, 400);
$reasoning = $this->faker->numberBetween(0, 2000);
$ttft = round($this->faker->randomFloat(2, 0.1, 4), 2);
$wall = round($ttft + $this->faker->randomFloat(2, 0.5, 60), 2);
$ok = $this->faker->boolean(70);
$payload = [
'level' => null, 'section' => null, 'name' => null, 'ok' => $ok,
'ttft' => $ttft, 'wall' => $wall,
];
return [
'benchmark_run_id' => BenchmarkRun::factory(),
'suite' => 'softbench',
'section' => $this->faker->randomElement(['code', 'reasoning', 'constraints', null]),
'task' => $this->faker->randomElement([
'first_occurrence', 'merge_intervals', 'version_compare', 'rbac_missing_verb', 'oom_innocent_victim',
]),
'level' => $this->faker->randomElement(['off', 'low', 'medium', 'xhigh']),
'ok' => $ok,
'decode' => round($this->faker->randomFloat(2, 10, 150), 2),
'user_tps' => round($this->faker->randomFloat(2, 5, 100), 2),
'ttft' => $ttft,
't_answer' => $ttft,
'wall' => $wall,
'reasoning' => $reasoning,
'visible' => $visible,
'total' => $visible + $reasoning,
'tool_calls' => null,
'checks_passed' => null,
'checks_total' => null,
'finish' => $ok ? 'stop' : $this->faker->randomElement(['stop', 'length']),
'detail' => null,
'payload' => $payload,
];
}
public function forSuite(string $suite): static
{
return $this->state(fn () => ['suite' => $suite]);
}
}

View file

@ -2,12 +2,13 @@
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
* @extends Factory<User>
*/
class UserFactory extends Factory
{

View file

@ -0,0 +1,95 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('benchmark_runs', function (Blueprint $table) {
$table->id();
$table->string('run_id', 32)->unique()->comment('festbench run_id');
$table->dateTime('ran_at')->nullable()->comment('ts from festbench');
$table->string('suite', 32);
$table->string('run_key', 255)->nullable();
// model + server identity (sensitive fields nullable, redacted by default)
$table->string('model_id', 255)->nullable();
$table->string('model_quant', 64)->nullable();
$table->string('model_quant_raw', 128)->nullable();
$table->string('model_path', 512)->nullable();
$table->string('engine', 64)->nullable();
$table->string('engine_build', 128)->nullable();
$table->unsignedBigInteger('n_ctx')->nullable();
$table->unsignedInteger('slots')->nullable();
$table->string('gpu', 128)->nullable();
$table->unsignedInteger('gpu_count')->nullable();
$table->string('gpu_source', 64)->nullable();
$table->string('host', 255)->nullable();
$table->string('client', 255)->nullable();
$table->string('endpoint', 512)->nullable();
$table->string('label', 255)->nullable();
$table->string('config', 255)->nullable();
// provenance
$table->string('image_digest', 512)->nullable();
$table->json('validators')->nullable();
$table->json('suite_versions')->nullable();
$table->string('harness', 32)->nullable();
$table->string('harness_ver', 64)->nullable();
$table->string('harness_mode', 64)->nullable();
// user-owned overlay — the only mutable part
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('visibility', 16)->default('public')->comment('public|private');
$table->text('notes')->nullable();
$table->timestamps();
$table->index('suite');
$table->index(['model_id', 'model_quant']);
$table->index(['visibility', 'user_id']);
});
Schema::create('run_results', function (Blueprint $table) {
$table->id();
$table->foreignId('benchmark_run_id')->constrained()->cascadeOnDelete();
$table->string('suite', 32);
$table->string('section', 64)->nullable();
$table->string('task', 128);
$table->string('level', 16)->nullable()->comment('off|low|medium|xhigh');
$table->boolean('ok')->nullable();
$table->double('decode')->nullable();
$table->double('user_tps')->nullable();
$table->double('ttft')->nullable();
$table->double('t_answer')->nullable();
$table->double('wall')->nullable();
$table->unsignedInteger('reasoning')->nullable();
$table->unsignedInteger('visible')->nullable();
$table->unsignedInteger('total')->nullable();
$table->unsignedInteger('tool_calls')->nullable();
$table->unsignedInteger('checks_passed')->nullable();
$table->unsignedInteger('checks_total')->nullable();
$table->string('finish', 32)->nullable();
$table->text('detail')->nullable();
// complete source row, so future festbench fields need no migration
$table->json('payload')->nullable();
$table->timestamps();
$table->index(['benchmark_run_id']);
$table->index(['suite', 'level']);
$table->index(['ok']);
});
}
public function down(): void
{
Schema::dropIfExists('run_results');
Schema::dropIfExists('benchmark_runs');
}
};

View file

@ -0,0 +1,7 @@
<x-layouts.app.header :title="$title ?? null">
<flux:main>
<div class="mx-auto w-full max-w-7xl">
{{ $slot }}
</div>
</flux:main>
</x-layouts.app.header>

View file

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Auth\VerifyEmailController;
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Route;
use Livewire\Volt\Volt;
@ -31,5 +32,5 @@
->name('password.confirm');
});
Route::post('logout', App\Livewire\Actions\Logout::class)
Route::post('logout', Logout::class)
->name('logout');

3
t.php Normal file
View file

@ -0,0 +1,3 @@
<?php
echo 'ok';

View file

@ -0,0 +1,72 @@
<?php
use App\Models\BenchmarkRun;
use App\Models\RunResult;
use App\Models\User;
test('guests see only public runs', function () {
$publicRun = BenchmarkRun::factory()->create(['visibility' => 'public']);
BenchmarkRun::factory()->create(['visibility' => 'private']);
$visible = BenchmarkRun::visibleTo(null)->get();
expect($visible->pluck('id'))->toContain($publicRun->id)
->and($visible)->toHaveCount(1);
});
test('authenticated users see public runs plus their own private runs', function () {
$me = User::factory()->create();
$other = User::factory()->create();
$publicRun = BenchmarkRun::factory()->create(['visibility' => 'public']);
$myPrivate = BenchmarkRun::factory()->create(['visibility' => 'private', 'user_id' => $me->id]);
BenchmarkRun::factory()->create(['visibility' => 'private', 'user_id' => $other->id]);
$visible = BenchmarkRun::visibleTo($me)->get();
expect($visible->pluck('id'))->toContain($publicRun->id)
->and($visible->pluck('id'))->toContain($myPrivate->id)
->and($visible)->toHaveCount(2);
});
test('submitted metrics are immutable through fillable updates', function () {
$run = BenchmarkRun::factory()->create(['model_id' => 'original/model', 'visibility' => 'public']);
// The only update path any user-facing code may use:
$run->fill(['notes' => 'my notes', 'visibility' => 'private', 'model_id' => 'hacked/model'])
->save();
expect($run->notes)->toBe('my notes')
->and($run->visibility)->toBe('private')
->and($run->fresh()->model_id)->toBe('original/model')
// fill() must have discarded the immutable field, not persisted it
->and($run->getOriginal('model_id'))->toBe('original/model');
});
test('overlay helper reports ownership correctly', function () {
$owner = User::factory()->create();
$run = BenchmarkRun::factory()->create(['user_id' => $owner->id]);
expect($run->isOwnedBy($owner))->toBeTrue()
->and($run->isOwnedBy(User::factory()->create()))->toBeFalse()
->and($run->isOwnedBy(null))->toBeFalse();
});
test('run results belong to a run and are deleted with it', function () {
$run = BenchmarkRun::factory()->create();
$result = RunResult::factory()->create(['benchmark_run_id' => $run->id]);
expect($result->run->id)->toBe($run->id);
$run->delete();
expect(RunResult::where('benchmark_run_id', $run->id)->exists())->toBeFalse();
});
test('run results cast their payload to array', function () {
$result = RunResult::factory()->create(['payload' => ['finish' => 'stop', 'ok' => true]]);
expect($result->fresh()->payload)->toBeArray()
->and($result->fresh()->payload['finish'])->toBe('stop')
->and($result->fresh()->ok)->toBeBool();
});

View file

@ -1,5 +1,8 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/*
|--------------------------------------------------------------------------
| Test Case
@ -11,8 +14,8 @@
|
*/
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
->in('Feature');
/*