Skip to content

Commit 5c37400

Browse files
ooplesfranklinicclaude
authored
feat(agentic): Phase 3 — local in-process inference (epic #1544) (#1552)
* feat(agentic): Phase 3 — local in-process inference engine (#1544) The flagship local-first differentiator: an IChatClient<T> that runs entirely in-process over AiDotNet's own model — no network, no API key. Same agent code drives it as drives OpenAI/Anthropic. - ICausalLanguageModel<T>: minimal next-token-logits seam the real Transformer (or any model) plugs into; implementations may keep an internal KV-cache. - IGenerationTokenizer (+TokenizerGenerationAdapter over the repo's ITokenizer): encode/decode/EOS seam. - TokenSampler<T>: greedy (temp 0 = argmax) + temperature + top-k + top-p (nucleus), seedable for reproducibility; reads logits via Convert.ToDouble so float/double share one path. - IChatPromptTemplate + ChatMlPromptTemplate (role-tagged, opens the assistant turn). - LocalEngineChatClient<T>: renders prompt -> encodes -> autoregressively samples until EOS or token limit -> decodes; non-streaming + streaming (incremental decode deltas); usage = prompt/generated token counts. Drop-in for agents/supervisor/swarm/memory. (Native tool-calling + structured-output constraints via constrained decoding are a follow-up; tools are ignored this slice.) - 9 tests (green net10.0 + net471) on a deterministic ScriptedCausalModel + WordTokenizer: greedy decode + EOS stop, token-limit -> Length, streaming deltas reconstruct full text, AgentExecutor drop-in, sampler argmax/seed-reproducibility/top-k/top-p, ChatML template. No null-forgiving. Stacked on Phase 2 (#1551) — the integration test shows the local engine driving an AgentExecutor. Part of epic #1544 (Phase 3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(agentic): Phase 3 — wire real AiDotNet networks into local inference (#1544) - NeuralNetworkCausalLanguageModel<T>: adapts a trained NeuralNetworkBase<T> LM (Mamba/GLA/Transformer-LM-head) to ICausalLanguageModel<T>. Encodes context as the one-hot [1,seq,vocab] tensor these models expect, runs a forward pass (ResetState first so recurrent models start fresh), and extracts the final position's logits (handles rank-2 and rank-3 outputs). Full context re-fed each step; KV-cached fast path is a follow-up. - 3 integration tests over a real tiny untrained MambaLanguageModel<double> (greedy, deterministic): adapter logit width + no-NaN, end-to-end generation honoring the token budget, streaming termination. Pinned to CpuEngine + non-parallel collection (documented GPU-autodetect mitigation). - Fixed Engine_IsDropInForAgentExecutor: it relied on the engine's stochastic default sampling while asserting a fixed output; now configures greedy (Temperature 0) so the assertion is deterministic. Root-caused the flake to default-temperature sampling, not a library/GPU issue. - Local suite green + stable (verified repeated runs) on net10.0 + net471. No null-forgiving. Part of epic #1544 (Phase 3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(agentic): Phase 3 — constrained decoding + stop sequences (#1544) Native LOCAL guaranteed-structure generation — enforced at the logits, not merely prompted (a capability cloud models can't guarantee): - ITokenConstraint seam: AllowedNextTokens(generated) returns null (free) / a set (restrict) / empty (terminal -> stop). TokenSampler.Sample gains an allowed-token mask honored by both greedy (argmax within allowed) and stochastic (candidates restricted before top-k/top-p). - AllowedTokenSetConstraint (fixed allow-list) + FiniteStateTokenConstraint (finite-state grammar over token ids: start set + per-token transitions; terminal states stop) — the general mechanism a JSON-schema/regular grammar compiles to. - LocalEngineOptions.Constraint wires it into LocalEngineChatClient generation (non-streaming + streaming). - Stop sequences: ChatOptions.StopSequences now halt generation and trim the output at the earliest match (both paths). - 5 tests (green, stable, net10.0 + net471): allowed-set greedy/stochastic masking, FSA forces an exact JSON-shaped sequence despite a model biased to 'garbage', allow-list restricts whole output, stop-sequence halts+trims. No null-forgiving. Part of epic #1544 (Phase 3). JSON-schema->token-grammar compiler (to drive structured output / tool-calling off this framework) is the next follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(agentic): Phase 3 — beam-search decoding (#1544) - LocalEngineOptions.BeamWidth (>1) switches non-streaming GetResponseAsync to beam search: explores N hypotheses in parallel, expands each by its top tokens (honoring the ITokenConstraint and stop sequences per beam), prunes to the top-N by length-normalized log-probability, and returns the best completion. Deterministic; streaming stays token-by-token. - LogSoftmax (with allowed-token masking → -inf) + length-normalized scoring + Beam bookkeeping. - 3 tests (green net10.0 + net471): greedy takes the locally-best token ("A"); beam width 2 discovers the globally better "BC" path the same model would miss greedily; beam respects a finite-state constraint ("AB"). No null-forgiving (removed an introduced allowed! via proper narrowing). Part of epic #1544 (Phase 3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(agentic): Phase 3 — incremental (KV-cache) decoding seam + engine fast path (#1544) - IIncrementalCausalLanguageModel<T> : ICausalLanguageModel<T> adds ResetCache / StartSequence(prompt) / AppendToken(id) — the contract a KV-cached model implements to advance one token at a time instead of re-feeding the full O(n^2) context. - LocalEngineChatClient auto-detects the interface and takes the incremental fast path (prime once, then feed single tokens), falling back to full re-feed otherwise. Constraint + stop-sequence + sampler logic shared via a single PickToken helper across both paths. - 2 tests (green net10.0 + net471): the engine drives the incremental path correctly (ResetCache once, StartSequence with the prompt, AppendToken per generated token) and produces output IDENTICAL to the full-refeed path. The remaining half — a real K/V cache inside Mamba/GLA/attention forward — is a model-layer change (the network Predict API has no incremental entry point yet); this lands the engine-side support + verification it plugs into. Part of epic #1544 (Phase 3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(agentic): Phase 3 — document quantization composition via ModelCompression (#1544) Quantization for the local engine is achieved by quantizing the NeuralNetworkBase<T> with the existing ModelCompression stack before wrapping it — the adapter accepts any such network unchanged. No engine-side code (no duplication of ModelCompression). Documented on NeuralNetworkCausalLanguageModel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: franklinic <franklin@ivorycloud.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 27a7c48 commit 5c37400

19 files changed

Lines changed: 2261 additions & 0 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
namespace AiDotNet.Agentic.Models.Local;
2+
3+
/// <summary>
4+
/// A constraint that always restricts generation to a fixed set of token ids, regardless of context — for
5+
/// example, "only emit digit tokens" or "only emit tokens from this closed label vocabulary".
6+
/// </summary>
7+
/// <remarks>
8+
/// <para><b>For Beginners:</b> The simplest gate: a permanent allow-list. Whatever has been generated, only
9+
/// these tokens are ever permitted next. Handy when the whole answer must come from a small known set.
10+
/// </para>
11+
/// </remarks>
12+
public sealed class AllowedTokenSetConstraint : ITokenConstraint
13+
{
14+
private readonly HashSet<int> _allowed;
15+
16+
/// <summary>
17+
/// Initializes a new constraint permitting only the given token ids.
18+
/// </summary>
19+
/// <param name="allowedTokenIds">The permitted token ids. Must be non-null and non-empty.</param>
20+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="allowedTokenIds"/> is <c>null</c>.</exception>
21+
/// <exception cref="ArgumentException">Thrown when <paramref name="allowedTokenIds"/> is empty.</exception>
22+
public AllowedTokenSetConstraint(IEnumerable<int> allowedTokenIds)
23+
{
24+
Guard.NotNull(allowedTokenIds);
25+
_allowed = new HashSet<int>(allowedTokenIds);
26+
if (_allowed.Count == 0)
27+
{
28+
throw new ArgumentException("At least one allowed token id is required.", nameof(allowedTokenIds));
29+
}
30+
}
31+
32+
/// <inheritdoc/>
33+
public IReadOnlyCollection<int>? AllowedNextTokens(IReadOnlyList<int> generatedTokenIds) => _allowed;
34+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.Text;
2+
using AiDotNet.Agentic.Models;
3+
4+
// Disambiguate from the legacy AiDotNet.PromptEngineering.Templates.ChatMessage (global using).
5+
using ChatMessage = AiDotNet.Agentic.Models.ChatMessage;
6+
7+
namespace AiDotNet.Agentic.Models.Local;
8+
9+
/// <summary>
10+
/// A ChatML-style <see cref="IChatPromptTemplate"/> that renders each message as
11+
/// <c>&lt;|role|&gt;\n{text}\n</c> and ends with an open <c>&lt;|assistant|&gt;</c> turn for the model to
12+
/// complete. This is a widely-used, model-agnostic default.
13+
/// </summary>
14+
/// <remarks>
15+
/// <para><b>For Beginners:</b> This writes the conversation like a script: each line is tagged with who is
16+
/// speaking (<c>&lt;|user|&gt;</c>, <c>&lt;|assistant|&gt;</c>, ...), and the script stops right where the
17+
/// assistant is about to speak — so the model fills in the assistant's reply.
18+
/// </para>
19+
/// </remarks>
20+
public sealed class ChatMlPromptTemplate : IChatPromptTemplate
21+
{
22+
/// <inheritdoc/>
23+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="messages"/> is <c>null</c>.</exception>
24+
/// <exception cref="ArgumentException">Thrown when <paramref name="messages"/> is empty.</exception>
25+
public string Render(IReadOnlyList<ChatMessage> messages)
26+
{
27+
Guard.NotNull(messages);
28+
if (messages.Count == 0)
29+
{
30+
throw new ArgumentException("The conversation must contain at least one message.", nameof(messages));
31+
}
32+
33+
var builder = new StringBuilder();
34+
foreach (var message in messages)
35+
{
36+
builder.Append("<|").Append(RoleTag(message.Role)).Append("|>\n");
37+
builder.Append(message.Text).Append('\n');
38+
}
39+
40+
builder.Append("<|assistant|>\n");
41+
return builder.ToString();
42+
}
43+
44+
private static string RoleTag(ChatRole role) => role switch
45+
{
46+
ChatRole.System => "system",
47+
ChatRole.User => "user",
48+
ChatRole.Assistant => "assistant",
49+
ChatRole.Tool => "tool",
50+
_ => "user"
51+
};
52+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
namespace AiDotNet.Agentic.Models.Local;
2+
3+
/// <summary>
4+
/// A constraint defined by a finite-state grammar over token ids: the set of allowed next tokens depends on
5+
/// the most recently generated token (the current state). This expresses exact sequences, branching choices,
6+
/// and loops — the general mechanism a JSON-schema or regular grammar compiles down to.
7+
/// </summary>
8+
/// <remarks>
9+
/// <para>
10+
/// State is the last generated token. Before any token is generated, the <c>start</c> set applies. From a
11+
/// state with no outgoing transitions, the empty set is returned, which tells the engine to stop — so a
12+
/// chain of single-token transitions forces an exact output, and terminal states end generation cleanly.
13+
/// </para>
14+
/// <para><b>For Beginners:</b> Picture a flowchart where each box says "from here, you may only go to these
15+
/// tokens next". Generation walks the flowchart; it can never step off it. Give each box exactly one exit and
16+
/// you force a precise output; give it several and you allow choices. Boxes with no exit end the answer.
17+
/// </para>
18+
/// </remarks>
19+
public sealed class FiniteStateTokenConstraint : ITokenConstraint
20+
{
21+
private readonly IReadOnlyCollection<int> _start;
22+
private readonly IReadOnlyDictionary<int, IReadOnlyCollection<int>> _transitions;
23+
24+
/// <summary>
25+
/// Initializes a new finite-state constraint.
26+
/// </summary>
27+
/// <param name="start">The tokens allowed as the very first generated token. Must be non-empty.</param>
28+
/// <param name="transitions">
29+
/// A map from a just-generated token id to the tokens allowed after it. A token absent from the map (or
30+
/// mapped to an empty set) is a terminal state at which generation stops.
31+
/// </param>
32+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="start"/> or <paramref name="transitions"/> is <c>null</c>.</exception>
33+
/// <exception cref="ArgumentException">Thrown when <paramref name="start"/> is empty.</exception>
34+
public FiniteStateTokenConstraint(
35+
IEnumerable<int> start,
36+
IReadOnlyDictionary<int, IReadOnlyCollection<int>> transitions)
37+
{
38+
Guard.NotNull(start);
39+
Guard.NotNull(transitions);
40+
_start = new List<int>(start);
41+
if (_start.Count == 0)
42+
{
43+
throw new ArgumentException("At least one start token id is required.", nameof(start));
44+
}
45+
46+
_transitions = transitions;
47+
}
48+
49+
/// <inheritdoc/>
50+
public IReadOnlyCollection<int>? AllowedNextTokens(IReadOnlyList<int> generatedTokenIds)
51+
{
52+
Guard.NotNull(generatedTokenIds);
53+
if (generatedTokenIds.Count == 0)
54+
{
55+
return _start;
56+
}
57+
58+
var lastToken = generatedTokenIds[generatedTokenIds.Count - 1];
59+
return _transitions.TryGetValue(lastToken, out var allowed) ? allowed : Array.Empty<int>();
60+
}
61+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using AiDotNet.LinearAlgebra;
2+
3+
namespace AiDotNet.Agentic.Models.Local;
4+
5+
/// <summary>
6+
/// The minimal contract an in-process language model exposes to the local generation engine: given the
7+
/// tokens seen so far, produce the logits for the next token. This is the seam between AiDotNet's own
8+
/// Transformer (or any other model) and <see cref="LocalEngineChatClient{T}"/>.
9+
/// </summary>
10+
/// <typeparam name="T">The tensor element type (e.g., <see cref="float"/> or <see cref="double"/>).</typeparam>
11+
/// <remarks>
12+
/// <para>
13+
/// Keeping the contract this small means the generation loop, sampling, and chat templating are written
14+
/// once and tested independently of any particular model, and the real network is wired in behind this
15+
/// interface. An implementation is free to maintain an internal KV-cache keyed on the growing context so
16+
/// repeated calls stay efficient; callers only ever ask for "the next-token logits given this context".
17+
/// </para>
18+
/// <para><b>For Beginners:</b> A language model, at its heart, answers one question over and over: "given
19+
/// everything so far, how likely is each possible next word-piece?" Those likelihoods (before turning them
20+
/// into probabilities) are called <em>logits</em>. This interface is exactly that one question, so the rest
21+
/// of the engine can focus on <em>choosing</em> the next token and stitching the words back together.
22+
/// </para>
23+
/// </remarks>
24+
public interface ICausalLanguageModel<T>
25+
{
26+
/// <summary>
27+
/// Gets the size of the model's vocabulary (the length of the logits vector returned by
28+
/// <see cref="NextTokenLogits"/>).
29+
/// </summary>
30+
int VocabularySize { get; }
31+
32+
/// <summary>
33+
/// Computes the next-token logits for the given context.
34+
/// </summary>
35+
/// <param name="tokenIds">The token ids of the context so far (prompt plus any tokens already generated). Must be non-empty.</param>
36+
/// <returns>A vector of length <see cref="VocabularySize"/> giving the unnormalized score for each candidate next token.</returns>
37+
Vector<T> NextTokenLogits(IReadOnlyList<int> tokenIds);
38+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using AiDotNet.Agentic.Models;
2+
3+
// Disambiguate from the legacy AiDotNet.PromptEngineering.Templates.ChatMessage (global using).
4+
using ChatMessage = AiDotNet.Agentic.Models.ChatMessage;
5+
6+
namespace AiDotNet.Agentic.Models.Local;
7+
8+
/// <summary>
9+
/// Renders a chat conversation (a list of <see cref="ChatMessage"/>) into the single prompt string a local
10+
/// language model is fed. Different model families expect different role markers, so this is pluggable.
11+
/// </summary>
12+
/// <remarks>
13+
/// <para><b>For Beginners:</b> Cloud chat APIs accept a list of messages directly, but a raw local model
14+
/// just continues one block of text. This converts the conversation into that block, tagging who said what
15+
/// (system/user/assistant) in the format the model was trained on, and ends with the assistant's turn so the
16+
/// model knows it should reply next.
17+
/// </para>
18+
/// </remarks>
19+
public interface IChatPromptTemplate
20+
{
21+
/// <summary>
22+
/// Renders the conversation into a prompt string.
23+
/// </summary>
24+
/// <param name="messages">The conversation so far. Must be non-empty.</param>
25+
/// <returns>The prompt text to encode and feed to the model.</returns>
26+
string Render(IReadOnlyList<ChatMessage> messages);
27+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
namespace AiDotNet.Agentic.Models.Local;
2+
3+
/// <summary>
4+
/// The minimal tokenizer contract the local generation engine needs: turn text into token ids, turn token
5+
/// ids back into text, and know which token marks end-of-sequence.
6+
/// </summary>
7+
/// <remarks>
8+
/// <para>
9+
/// This is intentionally narrower than the full <see cref="AiDotNet.Tokenization.Interfaces.ITokenizer"/> so
10+
/// the engine stays decoupled and trivially testable. <see cref="TokenizerGenerationAdapter"/> bridges a
11+
/// real repo tokenizer to this seam.
12+
/// </para>
13+
/// <para><b>For Beginners:</b> Models don't read text directly — they read numbers (token ids). This turns
14+
/// your prompt into those numbers, turns the model's numbers back into readable text, and tells the engine
15+
/// the special "stop here" token so it knows when the model is done.
16+
/// </para>
17+
/// </remarks>
18+
public interface IGenerationTokenizer
19+
{
20+
/// <summary>
21+
/// Gets the id of the end-of-sequence token. Generation stops when the model produces it. A negative
22+
/// value means "no EOS token" (generation then stops only at the token limit).
23+
/// </summary>
24+
int EosTokenId { get; }
25+
26+
/// <summary>
27+
/// Encodes text into token ids.
28+
/// </summary>
29+
/// <param name="text">The text to encode.</param>
30+
/// <returns>The token ids.</returns>
31+
IReadOnlyList<int> Encode(string text);
32+
33+
/// <summary>
34+
/// Decodes token ids back into text.
35+
/// </summary>
36+
/// <param name="tokenIds">The token ids to decode.</param>
37+
/// <returns>The decoded text.</returns>
38+
string Decode(IReadOnlyList<int> tokenIds);
39+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using AiDotNet.LinearAlgebra;
2+
3+
namespace AiDotNet.Agentic.Models.Local;
4+
5+
/// <summary>
6+
/// An <see cref="ICausalLanguageModel{T}"/> that supports incremental (KV-cached) decoding: it processes the
7+
/// prompt once, caches the per-position state, and then advances one token at a time without recomputing the
8+
/// whole sequence. This is the fast path for autoregressive generation.
9+
/// </summary>
10+
/// <typeparam name="T">The tensor element type.</typeparam>
11+
/// <remarks>
12+
/// <para>
13+
/// The base <see cref="ICausalLanguageModel{T}.NextTokenLogits"/> re-feeds the full context each step, which
14+
/// is correct but O(n²) over a generation. A model that maintains a key/value cache implements this interface
15+
/// so <see cref="LocalEngineChatClient{T}"/> can drive it incrementally: <see cref="StartSequence"/> primes
16+
/// the cache with the prompt and returns the first next-token logits, then each <see cref="AppendToken"/>
17+
/// feeds a single new token and returns the following logits. <see cref="ResetCache"/> clears state between
18+
/// independent generations.
19+
/// </para>
20+
/// <para><b>For Beginners:</b> Without caching, predicting each new word re-reads the entire conversation —
21+
/// slower and slower as it grows. With caching, the model remembers the work it already did and only looks at
22+
/// the one new word each time. This interface is how a model advertises "I can do the fast, remember-as-you-go
23+
/// version"; the engine uses it automatically when available and falls back to the simple way otherwise.
24+
/// </para>
25+
/// </remarks>
26+
public interface IIncrementalCausalLanguageModel<T> : ICausalLanguageModel<T>
27+
{
28+
/// <summary>
29+
/// Clears any cached decoding state, so the next <see cref="StartSequence"/> begins fresh.
30+
/// </summary>
31+
void ResetCache();
32+
33+
/// <summary>
34+
/// Processes the prompt, populating the cache, and returns the logits for the first generated token.
35+
/// </summary>
36+
/// <param name="promptTokenIds">The prompt token ids. Must be non-empty.</param>
37+
/// <returns>The next-token logits following the prompt (length = <see cref="ICausalLanguageModel{T}.VocabularySize"/>).</returns>
38+
Vector<T> StartSequence(IReadOnlyList<int> promptTokenIds);
39+
40+
/// <summary>
41+
/// Appends a single token to the cached context and returns the logits for the token after it.
42+
/// </summary>
43+
/// <param name="tokenId">The token id to append (typically the one just generated).</param>
44+
/// <returns>The next-token logits (length = <see cref="ICausalLanguageModel{T}.VocabularySize"/>).</returns>
45+
Vector<T> AppendToken(int tokenId);
46+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
namespace AiDotNet.Agentic.Models.Local;
2+
3+
/// <summary>
4+
/// Restricts which tokens the local engine may generate next, enabling <em>constrained decoding</em>: the
5+
/// model can only emit tokens the constraint permits, so the output is guaranteed to satisfy a structure
6+
/// (a fixed vocabulary, a grammar, a JSON shape) rather than merely being asked to in the prompt.
7+
/// </summary>
8+
/// <remarks>
9+
/// <para>
10+
/// At each step the engine calls <see cref="AllowedNextTokens"/> with the tokens generated so far. Returning
11+
/// <c>null</c> means "no restriction this step"; returning a set restricts sampling to exactly those token
12+
/// ids; returning an empty set tells the engine to stop (nothing valid can follow). This is the foundation
13+
/// for local structured output and tool-calling — capabilities cloud models approximate with prompting but
14+
/// cannot guarantee, and which a local engine can enforce at the logits because it controls decoding.
15+
/// </para>
16+
/// <para><b>For Beginners:</b> Normally a model can pick any next word-piece. A constraint is a gate that
17+
/// only lets through the choices that keep the answer valid — for example, only digits when you want a
18+
/// number, or only tokens that continue well-formed JSON. Because the gate is applied while generating, the
19+
/// result is always valid by construction, not just "usually" valid.
20+
/// </para>
21+
/// </remarks>
22+
public interface ITokenConstraint
23+
{
24+
/// <summary>
25+
/// Returns the token ids permitted as the next token given what has been generated so far.
26+
/// </summary>
27+
/// <param name="generatedTokenIds">The tokens generated since the prompt (excludes the prompt itself).</param>
28+
/// <returns>
29+
/// <c>null</c> for no restriction; a non-empty set to restrict sampling to those ids; an empty set to
30+
/// signal that generation should stop (no valid continuation exists).
31+
/// </returns>
32+
IReadOnlyCollection<int>? AllowedNextTokens(IReadOnlyList<int> generatedTokenIds);
33+
}

0 commit comments

Comments
 (0)