Gliner/Neural/GlinerClassificationEngine.cs
using System;
using System.Collections.Generic;
using GlinerPoc.Preprocessing;

namespace GlinerPoc.Neural;

/// <summary>
/// Phase 6.20 — model-facing classification result. No UI/service concerns.
/// "scores" are softmax candidate-distribution values, not calibrated
/// correctness probabilities.
/// </summary>
public sealed class GlinerDecisionResult
{
	public string[] Labels { get; }
	public float[] RawLogits { get; }
	public float[] ScaledLogits { get; }
	public float[] Scores { get; }
	public int SelectedIndex { get; }
	public string SelectedLabel => Labels[SelectedIndex];
	public int EncodedTokenCount { get; }

	public double PreprocessMs { get; internal set; }
	public double EmbeddingMs { get; internal set; }
	public double EncoderMs { get; internal set; }
	public double ClassifierMs { get; internal set; }
	public double TotalNeuralMs { get; internal set; }

	public GlinerDecisionResult( string[] labels, float[] rawLogits, float[] scaledLogits,
		float[] scores, int selectedIndex, int encodedTokenCount )
	{
		Labels = labels;
		RawLogits = rawLogits;
		ScaledLogits = scaledLogits;
		Scores = scores;
		SelectedIndex = selectedIndex;
		EncodedTokenCount = encodedTokenCount;
	}
}

/// <summary>
/// Phase 6.21/6.22 — top-level native GLiNER classification engine.
/// Pure managed inference: no Razor/scene/UI/NPC dependencies. Orchestrates
/// preprocessing → embeddings → 12-layer encoder → [L] marker gather →
/// classifier (384→768, ReLU, 768→1) → temperature → softmax → argmax.
/// The giant word embedding table stays on-demand (Phase 4 strategy);
/// layer weights use Strategy B: decoded once, retained (~85 MB).
/// </summary>
public sealed class GlinerClassificationEngine
{
	private readonly GlinerProcessor _processor;
	private readonly GlinerModelWeights _weights;
	private readonly GlinerDebertaLayer _layer;
	private readonly GlinerDebertaLayer.LayerWeights[] _layerWeights;
	private readonly float[] _normRel;
	private readonly float _temperature;
	private readonly float[] _classifier0W, _classifier0B, _classifier3W, _classifier3B;

	public double InitializationMs { get; private set; }

	public GlinerClassificationEngine( GlinerProcessor processor, GlinerModelWeights weights,
		float temperature, float[] embeddingLnW, float[] embeddingLnB,
		float[] relLnW, float[] relLnB )
	{
		_processor = processor;
		_weights = weights;
		_temperature = temperature;
		_layer = new GlinerDebertaLayer();

		var sw = System.Diagnostics.Stopwatch.StartNew();
		_normRel = GlinerMath.LayerNorm(
			weights.GetTensor( "encoder.encoder.rel_embeddings.weight" ), 512, 384,
			relLnW, relLnB, 1e-7f );
		EmbeddingLnW = embeddingLnW;
		EmbeddingLnB = embeddingLnB;

		_layerWeights = new GlinerDebertaLayer.LayerWeights[12];
		for ( int i = 0; i < 12; i++ )
		{
			_layerWeights[i] = LoadLayer( i );
		}

		_classifier0W = weights.GetTensor( "classifier.0.weight" );
		_classifier0B = weights.GetTensor( "classifier.0.bias" );
		_classifier3W = weights.GetTensor( "classifier.3.weight" );
		_classifier3B = weights.GetTensor( "classifier.3.bias" );
		InitializationMs = sw.ElapsedMilliseconds;
	}

	private GlinerDebertaLayer.LayerWeights LoadLayer( int i )
	{
		string p = $"encoder.encoder.layer.{i}.";
		return new GlinerDebertaLayer.LayerWeights
		{
			QueryW = _weights.GetTensor( p + "attention.self.query_proj.weight" ),
			QueryB = _weights.GetTensor( p + "attention.self.query_proj.bias" ),
			KeyW = _weights.GetTensor( p + "attention.self.key_proj.weight" ),
			KeyB = _weights.GetTensor( p + "attention.self.key_proj.bias" ),
			ValueW = _weights.GetTensor( p + "attention.self.value_proj.weight" ),
			ValueB = _weights.GetTensor( p + "attention.self.value_proj.bias" ),
			AttnDenseW = _weights.GetTensor( p + "attention.output.dense.weight" ),
			AttnDenseB = _weights.GetTensor( p + "attention.output.dense.bias" ),
			AttnLnW = _weights.GetTensor( p + "attention.output.LayerNorm.weight" ),
			AttnLnB = _weights.GetTensor( p + "attention.output.LayerNorm.bias" ),
			FfnUpW = _weights.GetTensor( p + "intermediate.dense.weight" ),
			FfnUpB = _weights.GetTensor( p + "intermediate.dense.bias" ),
			FfnDownW = _weights.GetTensor( p + "output.dense.weight" ),
			FfnDownB = _weights.GetTensor( p + "output.dense.bias" ),
			OutputLnW = _weights.GetTensor( p + "output.LayerNorm.weight" ),
			OutputLnB = _weights.GetTensor( p + "output.LayerNorm.bias" ),
		};
	}

	public float[] EmbeddingLnW { get; }
	public float[] EmbeddingLnB { get; }

	/// <summary>Shared normalized relative embeddings (computed once, reused by all layers).</summary>
	public float[] NormalizedRelEmbeddings => _normRel;

	/// <summary>Full request path: preprocessing + inference.</summary>
	public GlinerDecisionResult Classify( GlinerPoc.Preprocessing.GlinerClassificationRequest request,
		Dictionary<int, float[]> layerTrace = null )
	{
		var sw = System.Diagnostics.Stopwatch.StartNew();
		var encoded = _processor.Encode( request, collectDiagnostics: false );
		double preMs = sw.Elapsed.TotalMilliseconds;
		var result = ClassifyEncoded( encoded, layerTrace );
		result.PreprocessMs = preMs;
		return result;
	}

		/// <summary>Encoded-request path: neural inference only (Phase 6.22).</summary>
	public GlinerDecisionResult ClassifyEncoded(
		GlinerPoc.Preprocessing.GlinerEncodedRequest encoded,
		Dictionary<int, float[]> layerTrace = null )
	{
		bool ok = TryClassifyEncoded( encoded, out var result, layerTrace );
		return ok ? result : null;
	}

	/// <summary>
	/// Phase 7.10 — cancellation-capable inference. `cancelled` is polled at
	/// coarse safe boundaries only (after embeddings and between encoder
	/// layers); when it returns true the method returns false with result null
	/// and all scratch becomes garbage. No threading APIs are used.
	/// </summary>
	public bool TryClassifyEncoded(
		GlinerPoc.Preprocessing.GlinerEncodedRequest encoded,
		out GlinerDecisionResult result,
		Dictionary<int, float[]> layerTrace = null,
		Func<bool> cancelled = null )
	{
		result = null;
		var sw = System.Diagnostics.Stopwatch.StartNew();
		int S = encoded.InputIds.Length;

		// ---- embeddings (on-demand rows, never the full table) ----
		float[] gathered = new float[S * 384];
		for ( int i = 0; i < S; i++ )
		{
			float[] row = _weights.GetRows( "encoder.embeddings.word_embeddings.weight", encoded.InputIds[i], 1 );
			for ( int j = 0; j < 384; j++ )
			{
				gathered[i * 384 + j] = row[j];
			}
		}
		float[] hidden = GlinerMath.LayerNorm( gathered, S, 384, EmbeddingLnW, EmbeddingLnB, 1e-7f );
		if ( cancelled is not null && cancelled() )
		{
			return false;
		}
		double embMs = sw.Elapsed.TotalMilliseconds;

		// ---- relative setup (shared across all layers) ----
		int[,] relPos = GlinerRelativePositions.BuildRelativePositions( S, S, 256, -1 );

		// ---- 12-layer encoder (one generic layer implementation) ----
		for ( int i = 0; i < 12; i++ )
		{
			hidden = _layer.Forward( _layerWeights[i], hidden, S, encoded.AttentionMask, relPos, _normRel );
			layerTrace?.Add( i, hidden );
			if ( cancelled is not null && cancelled() )
			{
				return false;
			}
		}
		double encMs = sw.Elapsed.TotalMilliseconds - embMs;

		// ---- marker gather (Phase 3 indices, candidate order) ----
		int n = encoded.ClassificationMarkerIndices.Length;
		float[] markerStates = new float[n * 384];
		for ( int c = 0; c < n; c++ )
		{
			int row = encoded.ClassificationMarkerIndices[c];
			for ( int j = 0; j < 384; j++ )
			{
				markerStates[c * 384 + j] = hidden[row * 384 + j];
			}
		}

		// ---- classifier: 384→768, ReLU, 768→1 ----
		float[] clsHidden = GlinerMath.Linear( markerStates, n, _classifier0W, _classifier0B, 384, 768 );
		float[] relu = GlinerMath.Relu( clsHidden );
		float[] rawLogits = GlinerMath.Linear( relu, n, _classifier3W, _classifier3B, 768, 1 );
		double clsMs = sw.Elapsed.TotalMilliseconds - embMs - encMs;

		// ---- temperature + softmax + argmax ----
		float[] scaled = new float[n];
		for ( int i = 0; i < n; i++ )
		{
			scaled[i] = rawLogits[i] / _temperature;
		}
		float[] scores = Softmax1D( scaled );
		int best = 0;
		for ( int i = 1; i < n; i++ )
		{
			if ( scores[i] > scores[best] )
			{
				best = i; // strict > matches torch.argmax first-index tie behaviour
			}
		}

		result = new GlinerDecisionResult(
			encoded.CandidateOrder as string[] ?? CopyLabels( encoded.CandidateOrder ),
			rawLogits, scaled, scores, best, S )
		{
			EmbeddingMs = embMs,
			EncoderMs = encMs,
			ClassifierMs = clsMs,
			TotalNeuralMs = sw.Elapsed.TotalMilliseconds,
		};
		return true;
	}

	private static string[] CopyLabels( System.Collections.Generic.IReadOnlyList<string> order )
	{
		var copy = new string[order.Count];
		for ( int i = 0; i < order.Count; i++ )
		{
			copy[i] = order[i];
		}
		return copy;
	}

	/// <summary>Stable 1-D softmax over the candidate dimension.</summary>
	private static float[] Softmax1D( float[] x )
	{
		float max = x[0];
		for ( int i = 1; i < x.Length; i++ )
		{
			if ( x[i] > max )
			{
				max = x[i];
			}
		}
		var y = new float[x.Length];
		float sum = 0f;
		for ( int i = 0; i < x.Length; i++ )
		{
			float e = MathF.Exp( x[i] - max );
			y[i] = e;
			sum += e;
		}
		float inv = 1f / sum;
		for ( int i = 0; i < x.Length; i++ )
		{
			y[i] *= inv;
		}
		return y;
	}
}