Gliner/Preprocessing/GlinerTokenizer.cs
using System;
using System.Collections.Generic;
using System.Text;

namespace GlinerPoc.Preprocessing;

/// <summary>
/// Exact native implementation of the checkpoint's tokenizer pipeline for one
/// processor token (Phase 3.4–3.9):
///
///   raw token string
///     → split on added/special tokens (raw-text match, longest match)
///     → per non-special segment: normalize (regex \s{2,}|[\n\r\t] → " ", NFC,
///       right-strip whitespace) → Metaspace (split on whitespace, prefix ▁)
///     → per ▁-piece: Unigram Viterbi over the 128k vocabulary (double scores)
///     → unmatched characters become one [UNK] each (byte_fallback = false)
///
/// Built from the packaged tokenizer.json bytes; validated against the pinned
/// checkpoint identity. Tokenizer scores are f64 in the official Rust
/// implementation; all path selection here is double-precision.
/// </summary>
public sealed class GlinerTokenizer
{
	public const int ExpectedVocabSize = 128000;
	public const int UnkId = 3;
	private const double UnkPenalty = 10.0;

	private readonly Dictionary<string, int> _idByPiece = new( 128100, StringComparer.Ordinal );
	private double[] _scoreById = new double[128100];
	private readonly List<AddedToken> _addedTokens = new();
	private double _unkScore;
	private int _maxPieceChars;

	private sealed class AddedToken
	{
		public string Content;
		public int Id;
	}

	private GlinerTokenizer()
	{
	}

	public int VocabularySize => _idByPiece.Count;

	/// <summary>Map piece → id lookup used by tests/diagnostics.</summary>
	public int? TryGetId( string piece ) =>
		_idByPiece.TryGetValue( piece, out int id ) ? id : (int?)null;

	/// <summary>
	/// Parse + validate the packaged tokenizer JSON (HF tokenizers format,
	/// Unigram model). Throws with actionable messages on any identity or
	/// structure mismatch. Reports measured load milliseconds.
	/// </summary>
	public static GlinerTokenizer Load( byte[] tokenizerJsonBytes, out double loadMilliseconds )
	{
		var sw = System.Diagnostics.Stopwatch.StartNew();
		if ( tokenizerJsonBytes is null || tokenizerJsonBytes.Length == 0 )
		{
			throw new InvalidOperationException( "[GLI:ERROR] Tokenizer data is empty." );
		}

		string json = Encoding.UTF8.GetString( tokenizerJsonBytes );
		var dto = Json.Deserialize<TokenizerJsonDto>( json )
			?? throw new InvalidOperationException( "[GLI:ERROR] Tokenizer JSON deserialized to null." );

		var tokenizer = new GlinerTokenizer();
		tokenizer.Initialize( dto );
		loadMilliseconds = sw.ElapsedMilliseconds;
		return tokenizer;
	}

	private void Initialize( TokenizerJsonDto dto )
	{
		if ( dto.model is null )
		{
			throw new InvalidOperationException( "[GLI:ERROR] Tokenizer JSON has no model section." );
		}
		if ( dto.model.type != "Unigram" )
		{
			throw new InvalidOperationException(
				$"[GLI:ERROR] Tokenizer model type expected 'Unigram', found '{dto.model.type}'." );
		}
		if ( dto.model.byte_fallback )
		{
			throw new InvalidOperationException(
				"[GLI:ERROR] Unexpected byte_fallback=true; pinned tokenizer has it disabled." );
		}
		if ( dto.model.unk_id != UnkId )
		{
			throw new InvalidOperationException(
				$"[GLI:ERROR] Tokenizer unk_id expected {UnkId}, found {dto.model.unk_id}." );
		}
		if ( dto.model.vocab is null || dto.model.vocab.Count != ExpectedVocabSize )
		{
			throw new InvalidOperationException(
				$"[GLI:ERROR] Tokenizer vocab expected {ExpectedVocabSize} entries, found " +
				$"{dto.model.vocab?.Count ?? 0}." );
		}

		double minScore = double.PositiveInfinity;
		int maxPieceChars = 1;
		_idByPiece.Clear();
		Array.Clear( _scoreById, 0, _scoreById.Length );

		for ( int i = 0; i < dto.model.vocab.Count; i++ )
		{
			var pair = dto.model.vocab[i];
			if ( pair is null || pair.Count != 2 )
			{
				throw new InvalidOperationException(
					$"[GLI:ERROR] Tokenizer vocab entry {i} is not a [piece, score] pair." );
			}
			string piece = AsString( pair[0], $"vocab[{i}].piece" );
			double score = AsDouble( pair[1], $"vocab[{i}].score" );
			_idByPiece[piece] = i;
			_scoreById[i] = score;
			if ( score < minScore )
			{
				minScore = score;
			}
			if ( piece.Length > maxPieceChars )
			{
				maxPieceChars = piece.Length;
			}
		}

		// Added special tokens: exact pinned set, matched on raw text.
		var expectedSpecials = new (int Id, string Content)[]
		{
			(0, "[PAD]"), (1, "[CLS]"), (2, "[SEP]"), (3, "[UNK]"), (128000, "[MASK]"),
			(128001, "[SEP_STRUCT]"), (128002, "[SEP_TEXT]"), (128003, "[P]"),
			(128004, "[C]"), (128005, "[E]"), (128006, "[R]"), (128007, "[L]"),
			(128008, "[EXAMPLE]"), (128009, "[OUTPUT]"), (128010, "[DESCRIPTION]"),
		};
		if ( dto.added_tokens is null || dto.added_tokens.Length != expectedSpecials.Length )
		{
			throw new InvalidOperationException(
				$"[GLI:ERROR] Tokenizer added_tokens expected {expectedSpecials.Length}, found " +
				$"{dto.added_tokens?.Length ?? 0}." );
		}
		foreach ( var (id, content) in expectedSpecials )
		{
			bool found = false;
			foreach ( var at in dto.added_tokens )
			{
				if ( at.special && at.id == id && at.content == content )
				{
					found = true;
					break;
				}
			}
			if ( !found )
			{
				throw new InvalidOperationException(
					$"[GLI:ERROR] Tokenizer missing added special token id={id} '{content}'." );
			}
			_addedTokens.Add( new AddedToken { Id = id, Content = content } );
			_idByPiece[content] = id;
			if ( id >= _scoreById.Length )
			{
				Array.Resize( ref _scoreById, id + 1 );
			}
			_scoreById[id] = 0.0;
		}

		_unkScore = minScore - UnkPenalty;
		_maxPieceChars = maxPieceChars;
	}

	private static string AsString( object boxed, string what )
	{
		if ( boxed is System.Text.Json.JsonElement e && e.ValueKind == System.Text.Json.JsonValueKind.String )
		{
			return e.GetString();
		}
		throw new InvalidOperationException( $"[GLI:ERROR] Tokenizer {what} is not a string." );
	}

	private static double AsDouble( object boxed, string what )
	{
		if ( boxed is System.Text.Json.JsonElement e &&
			e.ValueKind == System.Text.Json.JsonValueKind.Number )
		{
			return e.GetDouble();
		}
		throw new InvalidOperationException( $"[GLI:ERROR] Tokenizer {what} is not a number." );
	}

	/// <summary>
	/// Tokenize one processor token (schema word or context word) exactly like
	/// the official tokenizer.tokenize(). Returns token IDs; when pieces is
	/// non-null, also appends the piece strings (debug/reference mode).
	/// </summary>
	public int[] TokenizeToken( string token, List<string> pieces )
	{
		var ids = new List<int>( 8 );
		int pos = 0;
		while ( pos < token.Length )
		{
			// Longest added-token match at the current raw position.
			int matchLen = 0;
			int matchId = -1;
			foreach ( var at in _addedTokens )
			{
				string content = at.Content;
				if ( content.Length > matchLen && pos + content.Length <= token.Length &&
					string.CompareOrdinal( token, pos, content, 0, content.Length ) == 0 )
				{
					matchLen = content.Length;
					matchId = at.Id;
				}
			}
			if ( matchLen > 0 )
			{
				ids.Add( matchId );
				pieces?.Add( token.Substring( pos, matchLen ) );
				pos += matchLen;
				continue;
			}

			// Advance to the end of the current non-special segment.
			int segEnd = pos + 1;
			while ( segEnd < token.Length )
			{
				bool isSpecial = false;
				foreach ( var at in _addedTokens )
				{
					if ( segEnd + at.Content.Length <= token.Length &&
						string.CompareOrdinal( token, segEnd, at.Content, 0, at.Content.Length ) == 0 )
					{
						isSpecial = true;
						break;
					}
				}
				if ( isSpecial )
				{
					break;
				}
				segEnd++;
			}

			EncodeSegment( token.Substring( pos, segEnd - pos ), ids, pieces );
			pos = segEnd;
		}
		return ids.ToArray();
	}

	/// <summary>
	/// Normalize + Metaspace + Unigram for one non-special segment.
	/// </summary>
	private void EncodeSegment( string segment, List<int> ids, List<string> pieces )
	{
		string normalized = Normalize( segment );
		int start = 0;
		int len = normalized.Length;
		while ( start < len )
		{
			while ( start < len && char.IsWhiteSpace( normalized[start] ) )
			{
				start++;
			}
			if ( start >= len )
			{
				break;
			}
			int end = start + 1;
			while ( end < len && !char.IsWhiteSpace( normalized[end] ) )
			{
				end++;
			}
			string piece = "\u2581" + normalized.Substring( start, end - start );
			ViterbiPiece( piece, ids, pieces );
			start = end;
		}
	}

	private static string Normalize( string input )
	{
		// 1) Replace (\s{2,} | [\n\r\t]) → " "
		string collapsed = CollapseWhitespace( input );
		// 2) NFC. NOTE: .NET Normalize throws on characters it considers invalid
		// (U+FFF9-FFFB, unpaired surrogates) where Python NFC is identity;
		// those characters have no decompositions, so identity is exact.
		string formC;
		try
		{
			formC = collapsed.Normalize( NormalizationForm.FormC );
		}
		catch ( ArgumentException )
		{
			formC = collapsed;
		}
		// 3) Strip right whitespace
		return formC.TrimEnd();
	}

	private static string CollapseWhitespace( string input )
	{
		// Official normalizer "Replace": regex (\s{2,} | [\n\r\t]) -> " ".
		// Single non-target whitespace passes through unchanged.
		var sb = new StringBuilder( input.Length );
		int i = 0;
		int n = input.Length;
		while ( i < n )
		{
			char c = input[i];
			if ( !char.IsWhiteSpace( c ) )
			{
				sb.Append( c );
				i++;
				continue;
			}
			int start = i;
			while ( i < n && char.IsWhiteSpace( input[i] ) )
			{
				i++;
			}
			int runLength = i - start;
			if ( runLength >= 2 )
			{
				sb.Append( ' ' );
			}
			else
			{
				char single = input[start];
				if ( single == '\n' || single == '\r' || single == '\t' )
				{
					sb.Append( ' ' );
				}
				else
				{
					sb.Append( single );
				}
			}
		}
		return sb.ToString();
	}

	/// <summary>
	/// Unigram Viterbi over one ▁-prefixed piece. A character with no
	/// single-char vocab piece becomes one [UNK] with score minScore − 10.0
	/// (fuse_unk = false). Strict-greater relaxation with left-to-right,
	/// shortest-first edge order reproduces the official tie-breaking.
	/// </summary>
	private void ViterbiPiece( string s, List<int> ids, List<string> pieces )
	{
		// Viterbi operates on CODE POINTS (the official tokenizer is
		// codepoint-based; UTF-16 units would split astral characters such as
		// emoji into two [UNK]s).
		int n = s.Length;
		var cpStart = new int[n + 1];
		int m = 0;
		for ( int i = 0; i < n; i++ )
		{
			cpStart[m++] = i;
			if ( char.IsHighSurrogate( s[i] ) && i + 1 < n && char.IsLowSurrogate( s[i + 1] ) )
			{
				i++;
			}
		}
		cpStart[m] = n;

		var best = new double[m + 1];
		var back = new int[m + 1];
		var backId = new int[m + 1];
		for ( int i = 1; i <= m; i++ )
		{
			best[i] = double.NegativeInfinity;
		}
		best[0] = 0.0;

		for ( int b = 0; b < m; b++ )
		{
			if ( double.IsNegativeInfinity( best[b] ) )
			{
				continue;
			}
			int maxEnd = b + _maxPieceChars;
			if ( maxEnd > m )
			{
				maxEnd = m;
			}
			bool hasSingleCodepointPiece = false;
			for ( int e = b + 1; e <= maxEnd; e++ )
			{
				string candidate = s.Substring( cpStart[b], cpStart[e] - cpStart[b] );
				if ( _idByPiece.TryGetValue( candidate, out int id ) )
				{
					if ( e == b + 1 )
					{
						hasSingleCodepointPiece = true;
					}
					double cand = best[b] + _scoreById[id];
					if ( cand > best[e] )
					{
						best[e] = cand;
						back[e] = b;
						backId[e] = id;
					}
				}
			}
			if ( !hasSingleCodepointPiece )
			{
				double cand2 = best[b] + _unkScore;
				if ( cand2 > best[b + 1] )
				{
					best[b + 1] = cand2;
					back[b + 1] = b;
					backId[b + 1] = UnkId;
				}
			}
		}

		// Backtrack (debug piece strings are recovered from the source span).
		var reversedIds = new List<int>( 8 );
		var reversedSpans = new List<(int From, int To)>( 8 );
		int pos = m;
		while ( pos > 0 )
		{
			reversedIds.Add( backId[pos] );
			reversedSpans.Add( (cpStart[back[pos]], cpStart[pos]) );
			pos = back[pos];
		}
		for ( int i = reversedIds.Count - 1; i >= 0; i-- )
		{
			ids.Add( reversedIds[i] );
			pieces?.Add( s.Substring( reversedSpans[i].From, reversedSpans[i].To - reversedSpans[i].From ) );
		}
	}

	private sealed class TokenizerJsonDto
	{
		public string version { get; set; }
		public AddedTokenDto[] added_tokens { get; set; }
		public ModelDto model { get; set; }
	}

	private sealed class AddedTokenDto
	{
		public int id { get; set; }
		public string content { get; set; }
		public bool special { get; set; }
	}

	private sealed class ModelDto
	{
		public string type { get; set; }
		public int unk_id { get; set; }
		public List<List<object>> vocab { get; set; }
		public bool byte_fallback { get; set; }
	}
}