festbench-web/app/Models/BenchmarkRun.php

170 lines
5.3 KiB
PHP

<?php
namespace App\Models;
use App\Support\Suites;
use Database\Factories\BenchmarkRunFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
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_id', 'gpu_source', 'config_id', '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. */
/**
* The only fields a user may change after submission. gpu/gpu_id are
* included deliberately: confirming a run's GPU against the canonical
* catalog is owner behavior (it links identity, it does not alter
* benchmark metrics).
*/
public const OVERLAY_FIELDS = ['visibility', 'notes', 'gpu', 'gpu_id', 'config_id'];
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 serverConfig(): BelongsTo
{
return $this->belongsTo(ServerConfig::class, 'config_id');
}
public function softbenchResults(): HasMany
{
return $this->hasMany(SoftbenchResult::class);
}
public function infrabenchResults(): HasMany
{
return $this->hasMany(InfrabenchResult::class);
}
public function prosebenchResults(): HasMany
{
return $this->hasMany(ProsebenchResult::class);
}
public function psychbenchResults(): HasMany
{
return $this->hasMany(PsychbenchResult::class);
}
public function scenarioRuns(): HasMany
{
return $this->hasMany(ScenarioRun::class);
}
public function depthsweepResults(): HasMany
{
return $this->hasMany(DepthsweepResult::class);
}
/**
* The suite-specific result rows for this run, via the registry. Returns
* an empty collection for suites this run does not have. An optional
* level filter narrows to one thinking level (depthsweep drill-in).
*/
public function suiteResults(?string $level = null): Collection
{
$model = Suites::modelFor($this->suite);
if ($model === null) {
return new Collection;
}
$relation = Suites::runRelation($this->suite);
return $this->{$relation}()
->when($level !== null && $level !== '', fn ($q) => $q->where('level', $level))
->orderBy('task')
->orderBy('id')
->get();
}
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(self::visibleClause($user));
}
/**
* The visibility predicate as a standalone closure, so joined queries
* (e.g. the combined results table) can apply the exact same rule
* without duplicating the logic. Accepts either builder flavor: Eloquent
* scopes pass an Eloquent Builder, DB::table queries pass a Query Builder.
*
* @return \Closure(\Illuminate\Database\Query\Builder|Builder): mixed
*/
public static function visibleClause(?User $user): \Closure
{
return function ($q) use ($user) {
$q->where('visibility', self::VISIBILITY_PUBLIC);
if ($user !== null) {
$q->orWhere(fn ($own) => $own
->where('visibility', self::VISIBILITY_PRIVATE)
->where('user_id', $user->id));
}
};
}
}