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;
	}
}