Gliner/Preprocessing/GlinerPreprocessingParity.cs
using System;
using System.Collections.Generic;
using System.Diagnostics;
using Sandbox;

namespace GlinerPoc.Preprocessing;

/// <summary>
/// Phase 3.18/3.19 parity harness: loads the packaged GlinerModelResource,
/// builds the native tokenizer + processor from the packaged tokenizer blob,
/// and compares native preprocessing against every embedded Python-oracle
/// fixture (Phase 1 originals + Phase 3 edge cases). No neural inference.
///
/// Comparison per fixture: context words, combined tokens, piece stream,
/// input IDs, attention mask, marker positions, marker token IDs, length.
/// Reports per-stage first mismatch and a deterministic summary.
/// </summary>
[Title( "GLiNER Preprocessing Parity" )]
[Category( "GLiNER" )]
public sealed class GlinerPreprocessingParity : Component
{
	[Property]
	public bool RunOnStart { get; set; } = true;

	[Property]
	public GlinerPoc.Packaging.GlinerModelResource ModelResource { get; set; }

	private int _passed;
	private int _failed;

	protected override void OnStart()
	{
		if ( RunOnStart )
		{
			_ = RunAsync();
		}
	}

	private async Task RunAsync()
	{
		var sw = Stopwatch.StartNew();
		Log.Info( "[GLI:PRE] parity harness start" );

		try
		{
			if ( ModelResource is null )
			{
				throw new InvalidOperationException( "[GLI:ERROR] No GlinerModelResource assigned." );
			}
			if ( ModelResource.FormatMagic != GlinerPoc.Packaging.GlinerModelResource.ExpectedMagic )
			{
				throw new InvalidOperationException( "[GLI:ERROR] Root resource magic mismatch." );
			}

			double loadMs = 0.0;
			GlinerTokenizer tokenizer = await Task.RunInThreadAsync( () =>
				GlinerTokenizer.Load( ModelResource.TokenizerData.Bytes, out loadMs ) );
			Log.Info( $"[GLI:PRE] tokenizer loaded bytes={ModelResource.TokenizerData.Bytes.Length:N0} " +
				$"vocab={tokenizer.VocabularySize:N0} init_ms={loadMs:N1}" );

			var processor = new GlinerProcessor( tokenizer );

			// Ground truth: confirms which fixture build is loaded at runtime.
			Log.Info( $"[GLI:PRE] fixture_build first='{PreprocessingFixturesV2.All[0].Name}' " +
				$"count={PreprocessingFixturesV2.All.Length} " +
				$"nbsp_words={PreprocessingFixturesV2.All[^1].ContextWords.Length}" );

			// cold pass + correctness
			var swEncode = Stopwatch.StartNew();
			foreach ( var fixture in PreprocessingFixturesV2.All )
			{
				CheckFixture( processor, fixture );
			}
			double coldMs = swEncode.ElapsedMilliseconds;

			// warm timing pass
			swEncode.Restart();
			int warmRuns = 0;
			for ( int rep = 0; rep < 5; rep++ )
			{
				foreach ( var fixture in PreprocessingFixturesV2.All )
				{
					try
					{
						var request = BuildRequest( fixture );
						processor.Encode( request, collectDiagnostics: false );
						warmRuns++;
					}
					catch
					{
						// Timing pass only; correctness failures already counted.
					}
				}
			}
			double warmPerSuiteMs = warmRuns > 0 ? swEncode.ElapsedMilliseconds / (double)warmRuns : 0;

			Log.Info( $"[GLI:PRE] parity complete passed={_passed} failed={_failed} " +
				$"fixtures={PreprocessingFixturesV2.All.Length} cold_ms={coldMs:N1} " +
				$"warm_runs={warmRuns} warm_per_request_ms={warmPerSuiteMs:N3}" );
			Log.Info( _failed == 0
				? $"[GLI:PRE] ALL PASS ({_passed}/{PreprocessingFixturesV2.All.Length})"
				: $"[GLI:PRE] FAILURES PRESENT ({_failed} of {PreprocessingFixturesV2.All.Length})" );
		}
		catch ( Exception error )
		{
			Log.Error( $"[GLI:PRE] harness failure: {error.Message}" );
		}
	}

	private static GlinerClassificationRequest BuildRequest( PreprocessingFixturesV2.Fixture f )
	{
		var candidates = new List<GlinerCandidate>( f.Labels.Length );
		for ( int i = 0; i < f.Labels.Length; i++ )
		{
			string description = null;
			for ( int d = 0; d < f.DescLabels.Length; d++ )
			{
				if ( f.DescLabels[d] == f.Labels[i] )
				{
					description = f.DescTexts[d];
					break;
				}
			}
			candidates.Add( new GlinerCandidate( f.Labels[i], description ) );
		}
		return new GlinerClassificationRequest( f.Context, f.Task, candidates );
	}

	private void CheckFixture( GlinerProcessor processor, PreprocessingFixturesV2.Fixture f )
	{
		try
		{
			var result = processor.Encode( BuildRequest( f ), collectDiagnostics: true );
			Compare( f.Name, "words", f.ContextWords, result.ContextWords );
			// Schema tokens are validated as the combined-tokens prefix
			// (combined = schema words + [SEP_TEXT] + context words).
			Compare( f.Name, "combined", f.CombinedTokens, result.CombinedTokens );
			Compare( f.Name, "pieces", FlattenPerTokenPieces( f.PerTokenPieces ), result.Pieces );
			Compare( f.Name, "ids", f.InputIds, result.InputIds );
			Compare( f.Name, "mask", f.AttentionMask, result.AttentionMask );
			Compare( f.Name, "markers", f.MarkerPositions, result.ClassificationMarkerIndices );

			var markerTokenIds = new int[f.MarkerPositions.Length];
			for ( int i = 0; i < f.MarkerPositions.Length; i++ )
			{
				markerTokenIds[i] = result.InputIds[f.MarkerPositions[i]];
			}
			Compare( f.Name, "marker_ids", f.MarkerTokenIds, markerTokenIds );

			if ( result.TokenCount != f.SequenceLength )
			{
				throw new InvalidOperationException(
					$"length expected {f.SequenceLength}, found {result.TokenCount}" );
			}

			_passed++;
			Log.Info( $"[GLI:PRE] PASS {f.Name} len={result.TokenCount} markers={result.ClassificationMarkerIndices.Length}" );
		}
		catch ( Exception error )
		{
			_failed++;
			Log.Error( $"[GLI:PRE] FAIL {f.Name}: {error.Message}" );
		}
	}

	private static string[] FlattenPerTokenPieces( string[][] perToken )
	{
		int total = 0;
		foreach ( var t in perToken )
		{
			total += t.Length;
		}
		var all = new string[total];
		int i = 0;
		foreach ( var t in perToken )
		{
			foreach ( var p in t )
			{
				all[i++] = p;
			}
		}
		return all;
	}

	private static void Compare( string name, string stage, string[] expected, string[] actual )
	{
		if ( expected.Length != actual.Length )
		{
			throw new InvalidOperationException(
				$"{stage}: count expected {expected.Length}, found {actual.Length}" );
		}
		for ( int i = 0; i < expected.Length; i++ )
		{
			if ( expected[i] != actual[i] )
			{
				throw new InvalidOperationException(
					$"{stage}[{i}]: expected '{expected[i]}', found '{actual[i]}'" );
			}
		}
	}

	private static void Compare( string name, string stage, int[] expected, int[] actual )
	{
		if ( expected.Length != actual.Length )
		{
			throw new InvalidOperationException(
				$"{stage}: count expected {expected.Length}, found {actual.Length}" );
		}
		for ( int i = 0; i < expected.Length; i++ )
		{
			if ( expected[i] != actual[i] )
			{
				throw new InvalidOperationException(
					$"{stage}[{i}]: expected {expected[i]}, found {actual[i]}" );
			}
		}
	}

	private static void Compare( string name, string stage, byte[] expected, byte[] actual )
	{
		if ( expected.Length != actual.Length )
		{
			throw new InvalidOperationException(
				$"{stage}: count expected {expected.Length}, found {actual.Length}" );
		}
		for ( int i = 0; i < expected.Length; i++ )
		{
			if ( expected[i] != actual[i] )
			{
				throw new InvalidOperationException(
					$"{stage}[{i}]: expected {expected[i]}, found {actual[i]}" );
			}
		}
	}
}