Gliner/GlinerDecisionService.cs
using System;
using System.Threading.Tasks;
using GlinerPoc.Preprocessing;
using Sandbox;
namespace GlinerPoc.Neural;
public enum GlinerServiceState
{
Uninitialized,
Initializing,
Ready,
Running,
Cancelling,
Faulted
}
public enum GlinerBackendKind
{
NativeGpuFp32,
NativeScalarCpu
}
/// <summary>
/// Phase 7 + Phase 8E — GLiNER classification service. Wraps the proven
/// native backends in a lifecycle-safe component: init once, one active
/// request, immutable request snapshots, request IDs + service generation
/// IDs for stale-result suppression, cooperative cancellation, and — since
/// P8E — explicit backend selection between Native GPU FP32 and Native
/// Scalar CPU behind IGlinerInferenceBackend. Preprocessing is shared: one
/// GlinerProcessor produces one encoded request consumed by whichever
/// backend is selected; backends differ ONLY in execution, never semantics.
///
/// Dependency direction: UI → this service → backend → engine. No Razor
/// references. Threading: service state mutates on the main thread; the
/// scalar backend runs on the worker thread, the GPU backend through the
/// render-context executor (owned by the GPU runtime, created with THIS
/// component's scene — never by the Razor panel).
/// </summary>
[Title( "GLiNER Decision Service" )]
[Category( "GLiNER" )]
public sealed class GlinerDecisionService : Component
{
[Property]
public GlinerPoc.Packaging.GlinerModelResource ModelResource { get; set; }
[Property]
public bool InitializeOnStart { get; set; } = true;
/// <summary>Backend selected when initialization completes (scene-saved default).</summary>
[Property]
public GlinerBackendKind DefaultBackend { get; set; } = GlinerBackendKind.NativeScalarCpu;
/// <summary>Controlled GPU-failure simulation for the P8E.30 failure-path test.</summary>
[Property]
public bool SimulateGpuFailure { get; set; } = false;
public GlinerServiceState State { get; private set; } = GlinerServiceState.Uninitialized;
public string ErrorMessage { get; private set; } = "";
public double InitializationMs { get; private set; }
public double GpuInitializationMs { get; private set; }
public long Generation { get; private set; }
public long ActiveRequestId { get; private set; }
public bool IsReady => State == GlinerServiceState.Ready;
public bool IsRunning => State == GlinerServiceState.Running || State == GlinerServiceState.Cancelling;
public GlinerBackendKind SelectedBackend { get; private set; }
public string BackendName => _selected?.Name ?? "uninitialized";
/// <summary>Backend that produced the last published result (diagnostics).</summary>
public string LastResultBackend { get; private set; } = "";
/// <summary>Concise GPU status for the UI (Ready/unused/failed + weight bytes).</summary>
public string GpuStatus { get; private set; } = "not initialized";
private GlinerClassificationEngine _cpuEngine;
private GlinerProcessor _processor;
private GlinerModelWeights _weights;
private GlinerPoc.Gpu.IGlinerInferenceBackend _cpuBackend;
private GlinerPoc.Gpu.GpuFp32Backend _gpuBackend;
private GlinerPoc.Gpu.IGlinerInferenceBackend _selected;
private Func<bool> _cancelCheck;
private bool _cancelRequested;
private long _nextRequestId;
protected override void OnStart()
{
if ( InitializeOnStart && State == GlinerServiceState.Uninitialized )
{
_ = InitializeAsync();
}
}
protected override void OnDisabled() => Invalidate( "component disabled" );
protected override void OnDestroy()
{
Invalidate( "component destroyed" );
_gpuBackend?.Dispose();
_gpuBackend = null;
}
/// <summary>
/// Phase 7.9/7.13 — invalidate in-flight work: generation bumps so any late
/// result is discarded. The decoded model stays loaded; a Running service
/// returns to Ready (not Uninitialized) so recovery needs no reload.
/// </summary>
private void Invalidate( string reason )
{
Generation++;
if ( State == GlinerServiceState.Running || State == GlinerServiceState.Cancelling )
{
State = _selected is null ? GlinerServiceState.Uninitialized : GlinerServiceState.Ready;
Log.Info( $"[GLI:SVC] invalidated in-flight work: {reason} (generation {Generation}, " +
$"state {State})" );
}
}
/// <summary>
/// Phase 8E.5 — initialize ONCE: common tokenizer/processor/weight access,
/// then the scalar backend and the GPU backend (executor hook → runtime/
/// shaders → resident weights → rel prep). GPU init failure is VISIBLE and
/// falls back to an explicitly-reported CPU selection (never a silent
/// false-GPU state).
/// </summary>
public async Task InitializeAsync()
{
if ( State != GlinerServiceState.Uninitialized )
{
return;
}
State = GlinerServiceState.Initializing;
ErrorMessage = "";
try
{
if ( ModelResource is null )
{
throw new InvalidOperationException(
"[GLI:ERROR] The GLiNER model resource is missing from this scene or its compiled " +
"assets did not load — the game cannot run classification without the bundled GLiNER " +
"model. (development builds: rebuild via the GLiNER/Build Model Resources editor tool)" );
}
var resource = ModelResource;
GlinerTokenizer tokenizer = null;
double tokMs = 0.0;
long gen = Generation;
// ---- common: tokenizer + weight access (shared by both backends;
// the GPU embedding gather reads rows through the same object)
await Task.RunInThreadAsync( () =>
{
tokenizer = GlinerTokenizer.Load( resource.TokenizerData.Bytes, out tokMs );
_weights = GlinerModelWeights.FromResource( resource );
} );
_processor = new GlinerProcessor( tokenizer );
float[] embLnW, embLnB, relLnW, relLnB;
await Task.RunInThreadAsync( () =>
{
embLnW = _weights.GetTensor( "encoder.embeddings.LayerNorm.weight" );
embLnB = _weights.GetTensor( "encoder.embeddings.LayerNorm.bias" );
relLnW = _weights.GetTensor( "encoder.encoder.LayerNorm.weight" );
relLnB = _weights.GetTensor( "encoder.encoder.LayerNorm.bias" );
} );
// re-fetch on main thread (worker closures can't assign locals used below)
embLnW = _weights.GetTensor( "encoder.embeddings.LayerNorm.weight" );
embLnB = _weights.GetTensor( "encoder.embeddings.LayerNorm.bias" );
relLnW = _weights.GetTensor( "encoder.encoder.LayerNorm.weight" );
relLnB = _weights.GetTensor( "encoder.encoder.LayerNorm.bias" );
// ---- scalar backend (permanent oracle; negligible extra memory —
// weights are shared through the same decode cache)
_cpuEngine = new GlinerClassificationEngine( _processor, _weights, 1.0f, embLnW, embLnB, relLnW, relLnB );
_cpuBackend = new GlinerPoc.Gpu.ScalarCpuBackend( _cpuEngine );
// ---- GPU backend (render-hook executor owned here, in THIS scene)
if ( SimulateGpuFailure )
{
throw new InvalidOperationException(
"[GLI:ERROR] Simulated GPU backend failure (SimulateGpuFailure=true)" );
}
var runtime = new GlinerPoc.Gpu.GlinerGpuRuntime();
runtime.Initialize( Scene ); // SceneCustomObject render hook — needs a valid scene
var model = new GlinerPoc.Gpu.GlinerGpuModelWeights( runtime );
model.LoadAndUpload( _weights ); // schema-validated one-time upload (main thread, ~13 ms)
var gpuEngine = new GlinerPoc.Gpu.GlinerGpuClassificationEngine(
runtime, model, _weights, embLnW, embLnB, 1.0f );
_gpuBackend = new GlinerPoc.Gpu.GpuFp32Backend( runtime, model, gpuEngine );
GpuInitializationMs = model.UploadMs + model.PrepareMs;
GpuStatus = $"Ready · {model.WeightBytes / (1024 * 1024):0.0} MiB resident";
await Task.Delay( 250 ); // let the rel-prep job drain before first request
if ( gen != Generation )
{
_gpuBackend.Dispose();
_gpuBackend = null;
return; // invalidated during init
}
// ---- backend selection (explicit fallback on failure, never silent)
GlinerBackendKind want = DefaultBackend;
if ( want == GlinerBackendKind.NativeGpuFp32 && _gpuBackend is null )
{
want = GlinerBackendKind.NativeScalarCpu;
}
_selected = want == GlinerBackendKind.NativeGpuFp32 ? _gpuBackend : _cpuBackend;
SelectedBackend = want;
InitializationMs = _cpuEngine.InitializationMs + tokMs + GpuInitializationMs;
State = GlinerServiceState.Ready;
Log.Info( $"[GLI:SVC] ready init_ms={InitializationMs:N1} (gpu={GpuInitializationMs:N1}) gen={Generation} " +
$"backend={BackendName} gpu=[{GpuStatus}] vocab={tokenizer.VocabularySize:N0}" );
}
catch ( System.Exception e )
{
// GPU path failure: CPU must remain usable, failure VISIBLE (8E.8/8E.30)
if ( _cpuBackend is not null )
{
_selected = _cpuBackend;
SelectedBackend = GlinerBackendKind.NativeScalarCpu;
GpuStatus = $"failed: {Friendly( e )}";
InitializationMs = _cpuEngine.InitializationMs;
State = GlinerServiceState.Ready;
ErrorMessage = $"GPU initialization failed — using Native Scalar CPU. ({Friendly( e )})";
Log.Error( $"[GLI:SVC] GPU init failed; CPU fallback active: {e.Message}" );
return;
}
State = GlinerServiceState.Faulted;
ErrorMessage = Friendly( e );
Log.Error( $"[GLI:SVC] initialization failed: {e}" );
}
}
/// <summary>
/// Phase 8E.10 — switch backend. Only while idle (Ready); an in-flight
/// request is never migrated. Switching is a pure selection change: no
/// generation bump is needed because no request is active.
/// </summary>
public bool SelectBackend( GlinerBackendKind kind )
{
if ( State != GlinerServiceState.Ready )
{
Log.Warning( $"[GLI:SVC] backend switch ignored (state {State})" );
return false;
}
if ( kind == GlinerBackendKind.NativeGpuFp32 && _gpuBackend is null )
{
Log.Warning( "[GLI:SVC] GPU backend unavailable — staying on CPU" );
return false;
}
SelectedBackend = kind;
_selected = kind == GlinerBackendKind.NativeGpuFp32 ? _gpuBackend : _cpuBackend;
Log.Info( $"[GLI:SVC] backend selected: {BackendName}" );
return true;
}
public bool GpuBackendAvailable => _gpuBackend is not null;
/// <summary>
/// Phase 7.6/7.7/7.8 + 8E — run one classification request through the
/// selected backend. The request is encoded into an immutable snapshot on
/// the main thread BEFORE execution starts. Returns the result, or null
/// when the request was cancelled/superseded.
/// </summary>
public async Task<GlinerDecisionResult> ClassifyAsync( GlinerClassificationRequest request )
{
if ( State != GlinerServiceState.Ready || _selected is null )
{
throw new InvalidOperationException(
$"[GLI:ERROR] Service is not Ready (state {State})." );
}
if ( IsRunning )
{
throw new InvalidOperationException( "[GLI:ERROR] A request is already active." );
}
// Phase 8E.29: invalid requests (incl. >256-token overflow) fail HERE,
// before any backend (and before the GPU) ever sees them.
var encoded = _processor.Encode( request, collectDiagnostics: false );
long requestId = ++_nextRequestId;
long gen = Generation;
ActiveRequestId = requestId;
_cancelRequested = false;
_cancelCheck = () => _cancelRequested;
State = GlinerServiceState.Running;
try
{
var result = await _selected.ClassifyEncodedAsync( encoded, _cancelCheck );
// Publish only if still owned (same request, same generation).
if ( gen != Generation || requestId != ActiveRequestId )
{
Log.Info( $"[GLI:SVC] stale result discarded (request {requestId}, gen {gen})" );
return null;
}
if ( result is null )
{
State = GlinerServiceState.Ready;
Log.Info( $"[GLI:SVC] request {requestId} cancelled ({BackendName})" );
return null;
}
State = GlinerServiceState.Ready;
LastResultBackend = BackendName;
return result;
}
catch ( Exception e )
{
// Phase 7.44 — containment: failures never leave the service Running.
if ( gen == Generation && requestId == ActiveRequestId && State == GlinerServiceState.Running )
{
State = GlinerServiceState.Ready;
}
ErrorMessage = Friendly( e );
Log.Error( $"[GLI:SVC] request {requestId} failed: {e}" );
throw;
}
finally
{
_cancelCheck = null;
_cancelRequested = false;
}
}
/// <summary>
/// Phase 7.12 + 8E.12/8E.13 — cancel. Scalar: observed between encoder
/// layers. GPU: queued jobs are dropped, dispatched jobs complete and the
/// result is discarded by ownership checks; the GPU is never aborted
/// mid-kernel and shared buffers are never destroyed to simulate cancel.
/// </summary>
public void Cancel()
{
if ( State == GlinerServiceState.Running )
{
State = GlinerServiceState.Cancelling;
_cancelRequested = true;
Log.Info( $"[GLI:SVC] cancel requested (backend {BackendName})" );
}
}
private static string Friendly( Exception e )
{
string m = e?.Message ?? "Unknown error.";
if ( m.StartsWith( "[GLI:ERROR] " ) )
{
m = m[12..];
}
return m.Length > 240 ? m[..237] + "..." : m;
}
}