Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/Agentic/Models/Local/AllowedTokenSetConstraint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace AiDotNet.Agentic.Models.Local;

/// <summary>
/// A constraint that always restricts generation to a fixed set of token ids, regardless of context — for
/// example, "only emit digit tokens" or "only emit tokens from this closed label vocabulary".
/// </summary>
/// <remarks>
/// <para><b>For Beginners:</b> The simplest gate: a permanent allow-list. Whatever has been generated, only
/// these tokens are ever permitted next. Handy when the whole answer must come from a small known set.
/// </para>
/// </remarks>
public sealed class AllowedTokenSetConstraint : ITokenConstraint
{
private readonly HashSet<int> _allowed;

/// <summary>
/// Initializes a new constraint permitting only the given token ids.
/// </summary>
/// <param name="allowedTokenIds">The permitted token ids. Must be non-null and non-empty.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="allowedTokenIds"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="allowedTokenIds"/> is empty.</exception>
public AllowedTokenSetConstraint(IEnumerable<int> allowedTokenIds)
{
Guard.NotNull(allowedTokenIds);
_allowed = new HashSet<int>(allowedTokenIds);
if (_allowed.Count == 0)
{
throw new ArgumentException("At least one allowed token id is required.", nameof(allowedTokenIds));
}
}

/// <inheritdoc/>
public IReadOnlyCollection<int>? AllowedNextTokens(IReadOnlyList<int> generatedTokenIds) => _allowed;
}
52 changes: 52 additions & 0 deletions src/Agentic/Models/Local/ChatMlPromptTemplate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Text;
using AiDotNet.Agentic.Models;

// Disambiguate from the legacy AiDotNet.PromptEngineering.Templates.ChatMessage (global using).
using ChatMessage = AiDotNet.Agentic.Models.ChatMessage;

namespace AiDotNet.Agentic.Models.Local;

/// <summary>
/// A ChatML-style <see cref="IChatPromptTemplate"/> that renders each message as
/// <c>&lt;|role|&gt;\n{text}\n</c> and ends with an open <c>&lt;|assistant|&gt;</c> turn for the model to
/// complete. This is a widely-used, model-agnostic default.
/// </summary>
/// <remarks>
/// <para><b>For Beginners:</b> This writes the conversation like a script: each line is tagged with who is
/// speaking (<c>&lt;|user|&gt;</c>, <c>&lt;|assistant|&gt;</c>, ...), and the script stops right where the
/// assistant is about to speak — so the model fills in the assistant's reply.
/// </para>
/// </remarks>
public sealed class ChatMlPromptTemplate : IChatPromptTemplate
{
/// <inheritdoc/>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="messages"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="messages"/> is empty.</exception>
public string Render(IReadOnlyList<ChatMessage> messages)
{
Guard.NotNull(messages);
if (messages.Count == 0)
{
throw new ArgumentException("The conversation must contain at least one message.", nameof(messages));
}

var builder = new StringBuilder();
foreach (var message in messages)
{
builder.Append("<|").Append(RoleTag(message.Role)).Append("|>\n");
builder.Append(message.Text).Append('\n');
}

builder.Append("<|assistant|>\n");
return builder.ToString();
}

private static string RoleTag(ChatRole role) => role switch
{
ChatRole.System => "system",
ChatRole.User => "user",
ChatRole.Assistant => "assistant",
ChatRole.Tool => "tool",
_ => "user"
};
}
61 changes: 61 additions & 0 deletions src/Agentic/Models/Local/FiniteStateTokenConstraint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
namespace AiDotNet.Agentic.Models.Local;

/// <summary>
/// A constraint defined by a finite-state grammar over token ids: the set of allowed next tokens depends on
/// the most recently generated token (the current state). This expresses exact sequences, branching choices,
/// and loops — the general mechanism a JSON-schema or regular grammar compiles down to.
/// </summary>
/// <remarks>
/// <para>
/// State is the last generated token. Before any token is generated, the <c>start</c> set applies. From a
/// state with no outgoing transitions, the empty set is returned, which tells the engine to stop — so a
/// chain of single-token transitions forces an exact output, and terminal states end generation cleanly.
/// </para>
/// <para><b>For Beginners:</b> Picture a flowchart where each box says "from here, you may only go to these
/// tokens next". Generation walks the flowchart; it can never step off it. Give each box exactly one exit and
/// you force a precise output; give it several and you allow choices. Boxes with no exit end the answer.
/// </para>
/// </remarks>
public sealed class FiniteStateTokenConstraint : ITokenConstraint
{
private readonly IReadOnlyCollection<int> _start;
private readonly IReadOnlyDictionary<int, IReadOnlyCollection<int>> _transitions;

/// <summary>
/// Initializes a new finite-state constraint.
/// </summary>
/// <param name="start">The tokens allowed as the very first generated token. Must be non-empty.</param>
/// <param name="transitions">
/// A map from a just-generated token id to the tokens allowed after it. A token absent from the map (or
/// mapped to an empty set) is a terminal state at which generation stops.
/// </param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="start"/> or <paramref name="transitions"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="start"/> is empty.</exception>
public FiniteStateTokenConstraint(
IEnumerable<int> start,
IReadOnlyDictionary<int, IReadOnlyCollection<int>> transitions)
{
Guard.NotNull(start);
Guard.NotNull(transitions);
_start = new List<int>(start);
if (_start.Count == 0)
{
throw new ArgumentException("At least one start token id is required.", nameof(start));
}

_transitions = transitions;
}

/// <inheritdoc/>
public IReadOnlyCollection<int>? AllowedNextTokens(IReadOnlyList<int> generatedTokenIds)
{
Guard.NotNull(generatedTokenIds);
if (generatedTokenIds.Count == 0)
{
return _start;
}

var lastToken = generatedTokenIds[generatedTokenIds.Count - 1];
return _transitions.TryGetValue(lastToken, out var allowed) ? allowed : Array.Empty<int>();
}
}
38 changes: 38 additions & 0 deletions src/Agentic/Models/Local/ICausalLanguageModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using AiDotNet.LinearAlgebra;

namespace AiDotNet.Agentic.Models.Local;

/// <summary>
/// The minimal contract an in-process language model exposes to the local generation engine: given the
/// tokens seen so far, produce the logits for the next token. This is the seam between AiDotNet's own
/// Transformer (or any other model) and <see cref="LocalEngineChatClient{T}"/>.
/// </summary>
/// <typeparam name="T">The tensor element type (e.g., <see cref="float"/> or <see cref="double"/>).</typeparam>
/// <remarks>
/// <para>
/// Keeping the contract this small means the generation loop, sampling, and chat templating are written
/// once and tested independently of any particular model, and the real network is wired in behind this
/// interface. An implementation is free to maintain an internal KV-cache keyed on the growing context so
/// repeated calls stay efficient; callers only ever ask for "the next-token logits given this context".
/// </para>
/// <para><b>For Beginners:</b> A language model, at its heart, answers one question over and over: "given
/// everything so far, how likely is each possible next word-piece?" Those likelihoods (before turning them
/// into probabilities) are called <em>logits</em>. This interface is exactly that one question, so the rest
/// of the engine can focus on <em>choosing</em> the next token and stitching the words back together.
/// </para>
/// </remarks>
public interface ICausalLanguageModel<T>
{
/// <summary>
/// Gets the size of the model's vocabulary (the length of the logits vector returned by
/// <see cref="NextTokenLogits"/>).
/// </summary>
int VocabularySize { get; }

/// <summary>
/// Computes the next-token logits for the given context.
/// </summary>
/// <param name="tokenIds">The token ids of the context so far (prompt plus any tokens already generated). Must be non-empty.</param>
/// <returns>A vector of length <see cref="VocabularySize"/> giving the unnormalized score for each candidate next token.</returns>
Vector<T> NextTokenLogits(IReadOnlyList<int> tokenIds);
}
27 changes: 27 additions & 0 deletions src/Agentic/Models/Local/IChatPromptTemplate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using AiDotNet.Agentic.Models;

// Disambiguate from the legacy AiDotNet.PromptEngineering.Templates.ChatMessage (global using).
using ChatMessage = AiDotNet.Agentic.Models.ChatMessage;

namespace AiDotNet.Agentic.Models.Local;

/// <summary>
/// Renders a chat conversation (a list of <see cref="ChatMessage"/>) into the single prompt string a local
/// language model is fed. Different model families expect different role markers, so this is pluggable.
/// </summary>
/// <remarks>
/// <para><b>For Beginners:</b> Cloud chat APIs accept a list of messages directly, but a raw local model
/// just continues one block of text. This converts the conversation into that block, tagging who said what
/// (system/user/assistant) in the format the model was trained on, and ends with the assistant's turn so the
/// model knows it should reply next.
/// </para>
/// </remarks>
public interface IChatPromptTemplate
{
/// <summary>
/// Renders the conversation into a prompt string.
/// </summary>
/// <param name="messages">The conversation so far. Must be non-empty.</param>
/// <returns>The prompt text to encode and feed to the model.</returns>
string Render(IReadOnlyList<ChatMessage> messages);
}
39 changes: 39 additions & 0 deletions src/Agentic/Models/Local/IGenerationTokenizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
namespace AiDotNet.Agentic.Models.Local;

/// <summary>
/// The minimal tokenizer contract the local generation engine needs: turn text into token ids, turn token
/// ids back into text, and know which token marks end-of-sequence.
/// </summary>
/// <remarks>
/// <para>
/// This is intentionally narrower than the full <see cref="AiDotNet.Tokenization.Interfaces.ITokenizer"/> so
/// the engine stays decoupled and trivially testable. <see cref="TokenizerGenerationAdapter"/> bridges a
/// real repo tokenizer to this seam.
/// </para>
/// <para><b>For Beginners:</b> Models don't read text directly — they read numbers (token ids). This turns
/// your prompt into those numbers, turns the model's numbers back into readable text, and tells the engine
/// the special "stop here" token so it knows when the model is done.
/// </para>
/// </remarks>
public interface IGenerationTokenizer
{
/// <summary>
/// Gets the id of the end-of-sequence token. Generation stops when the model produces it. A negative
/// value means "no EOS token" (generation then stops only at the token limit).
/// </summary>
int EosTokenId { get; }

/// <summary>
/// Encodes text into token ids.
/// </summary>
/// <param name="text">The text to encode.</param>
/// <returns>The token ids.</returns>
IReadOnlyList<int> Encode(string text);

/// <summary>
/// Decodes token ids back into text.
/// </summary>
/// <param name="tokenIds">The token ids to decode.</param>
/// <returns>The decoded text.</returns>
string Decode(IReadOnlyList<int> tokenIds);
}
46 changes: 46 additions & 0 deletions src/Agentic/Models/Local/IIncrementalCausalLanguageModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using AiDotNet.LinearAlgebra;

namespace AiDotNet.Agentic.Models.Local;

/// <summary>
/// An <see cref="ICausalLanguageModel{T}"/> that supports incremental (KV-cached) decoding: it processes the
/// prompt once, caches the per-position state, and then advances one token at a time without recomputing the
/// whole sequence. This is the fast path for autoregressive generation.
/// </summary>
/// <typeparam name="T">The tensor element type.</typeparam>
/// <remarks>
/// <para>
/// The base <see cref="ICausalLanguageModel{T}.NextTokenLogits"/> re-feeds the full context each step, which
/// is correct but O(n²) over a generation. A model that maintains a key/value cache implements this interface
/// so <see cref="LocalEngineChatClient{T}"/> can drive it incrementally: <see cref="StartSequence"/> primes
/// the cache with the prompt and returns the first next-token logits, then each <see cref="AppendToken"/>
/// feeds a single new token and returns the following logits. <see cref="ResetCache"/> clears state between
/// independent generations.
/// </para>
/// <para><b>For Beginners:</b> Without caching, predicting each new word re-reads the entire conversation —
/// slower and slower as it grows. With caching, the model remembers the work it already did and only looks at
/// the one new word each time. This interface is how a model advertises "I can do the fast, remember-as-you-go
/// version"; the engine uses it automatically when available and falls back to the simple way otherwise.
/// </para>
/// </remarks>
public interface IIncrementalCausalLanguageModel<T> : ICausalLanguageModel<T>
{
/// <summary>
/// Clears any cached decoding state, so the next <see cref="StartSequence"/> begins fresh.
/// </summary>
void ResetCache();

/// <summary>
/// Processes the prompt, populating the cache, and returns the logits for the first generated token.
/// </summary>
/// <param name="promptTokenIds">The prompt token ids. Must be non-empty.</param>
/// <returns>The next-token logits following the prompt (length = <see cref="ICausalLanguageModel{T}.VocabularySize"/>).</returns>
Vector<T> StartSequence(IReadOnlyList<int> promptTokenIds);

/// <summary>
/// Appends a single token to the cached context and returns the logits for the token after it.
/// </summary>
/// <param name="tokenId">The token id to append (typically the one just generated).</param>
/// <returns>The next-token logits (length = <see cref="ICausalLanguageModel{T}.VocabularySize"/>).</returns>
Vector<T> AppendToken(int tokenId);
}
33 changes: 33 additions & 0 deletions src/Agentic/Models/Local/ITokenConstraint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
namespace AiDotNet.Agentic.Models.Local;

/// <summary>
/// Restricts which tokens the local engine may generate next, enabling <em>constrained decoding</em>: the
/// model can only emit tokens the constraint permits, so the output is guaranteed to satisfy a structure
/// (a fixed vocabulary, a grammar, a JSON shape) rather than merely being asked to in the prompt.
/// </summary>
/// <remarks>
/// <para>
/// At each step the engine calls <see cref="AllowedNextTokens"/> with the tokens generated so far. Returning
/// <c>null</c> means "no restriction this step"; returning a set restricts sampling to exactly those token
/// ids; returning an empty set tells the engine to stop (nothing valid can follow). This is the foundation
/// for local structured output and tool-calling — capabilities cloud models approximate with prompting but
/// cannot guarantee, and which a local engine can enforce at the logits because it controls decoding.
/// </para>
/// <para><b>For Beginners:</b> Normally a model can pick any next word-piece. A constraint is a gate that
/// only lets through the choices that keep the answer valid — for example, only digits when you want a
/// number, or only tokens that continue well-formed JSON. Because the gate is applied while generating, the
/// result is always valid by construction, not just "usually" valid.
/// </para>
/// </remarks>
public interface ITokenConstraint
{
/// <summary>
/// Returns the token ids permitted as the next token given what has been generated so far.
/// </summary>
/// <param name="generatedTokenIds">The tokens generated since the prompt (excludes the prompt itself).</param>
/// <returns>
/// <c>null</c> for no restriction; a non-empty set to restrict sampling to those ids; an empty set to
/// signal that generation should stop (no valid continuation exists).
/// </returns>
IReadOnlyCollection<int>? AllowedNextTokens(IReadOnlyList<int> generatedTokenIds);
}
Loading
Loading