Skip to content

Commit d3284f6

Browse files
franklinicclaude
andcommitted
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>
1 parent 1397b4d commit d3284f6

3 files changed

Lines changed: 312 additions & 5 deletions

File tree

src/Agentic/Models/Local/LocalEngineChatClient.cs

Lines changed: 161 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,23 @@ public Task<ChatResponse> GetResponseAsync(
7171
CancellationToken cancellationToken = default)
7272
{
7373
var promptIds = BuildPromptIds(messages);
74-
var sampling = ResolveSampling(options);
75-
var sampler = new TokenSampler<T>(sampling.Seed);
7674
var maxTokens = ResolveMaxTokens(options);
77-
7875
var stopSequences = ResolveStopSequences(options);
79-
var generated = new List<int>();
80-
var finishReason = RunGeneration(promptIds, maxTokens, sampler, sampling, stopSequences, generated, cancellationToken);
76+
77+
List<int> generated;
78+
ChatFinishReason finishReason;
79+
var beamWidth = _options.BeamWidth is { } width && width > 1 ? width : 1;
80+
if (beamWidth > 1)
81+
{
82+
(generated, finishReason) = RunBeamSearch(promptIds, maxTokens, beamWidth, stopSequences, cancellationToken);
83+
}
84+
else
85+
{
86+
var sampling = ResolveSampling(options);
87+
var sampler = new TokenSampler<T>(sampling.Seed);
88+
generated = new List<int>();
89+
finishReason = RunGeneration(promptIds, maxTokens, sampler, sampling, stopSequences, generated, cancellationToken);
90+
}
8191

8292
var text = generated.Count > 0 ? _tokenizer.Decode(generated) : string.Empty;
8393
text = TrimAtStopSequence(text, stopSequences);
@@ -194,6 +204,152 @@ private ChatFinishReason RunGeneration(
194204
return sampler.Sample(logits, sampling, allowed);
195205
}
196206

207+
// Beam search: explore `beamWidth` hypotheses in parallel, expanding each by its top tokens (honoring
208+
// the constraint), and keep the highest-scoring (length-normalized log-probability) beams. Returns the
209+
// best completion. Deterministic.
210+
private (List<int> Tokens, ChatFinishReason Finish) RunBeamSearch(
211+
IReadOnlyList<int> promptIds,
212+
int maxTokens,
213+
int beamWidth,
214+
IReadOnlyList<string>? stopSequences,
215+
CancellationToken cancellationToken)
216+
{
217+
var beams = new List<Beam> { new(new List<int>(), 0.0, finished: false) };
218+
219+
for (var step = 0; step < maxTokens; step++)
220+
{
221+
cancellationToken.ThrowIfCancellationRequested();
222+
if (beams.All(b => b.Finished))
223+
{
224+
break;
225+
}
226+
227+
var expanded = new List<Beam>();
228+
foreach (var beam in beams)
229+
{
230+
if (beam.Finished)
231+
{
232+
expanded.Add(beam);
233+
continue;
234+
}
235+
236+
IReadOnlyCollection<int>? allowed = null;
237+
if (_options.Constraint is { } constraint)
238+
{
239+
allowed = constraint.AllowedNextTokens(beam.Tokens);
240+
if (allowed is not null && allowed.Count == 0)
241+
{
242+
expanded.Add(new Beam(beam.Tokens, beam.LogProbability, finished: true));
243+
continue;
244+
}
245+
}
246+
247+
var context = new List<int>(promptIds.Count + beam.Tokens.Count);
248+
context.AddRange(promptIds);
249+
context.AddRange(beam.Tokens);
250+
251+
var logProbabilities = LogSoftmax(_model.NextTokenLogits(context), allowed);
252+
foreach (var (tokenId, logProbability) in TopTokens(logProbabilities, beamWidth))
253+
{
254+
var finished = tokenId == _tokenizer.EosTokenId;
255+
var tokens = new List<int>(beam.Tokens);
256+
if (!finished)
257+
{
258+
tokens.Add(tokenId);
259+
}
260+
261+
if (!finished && stopSequences is not null && ContainsStopSequence(_tokenizer.Decode(tokens), stopSequences))
262+
{
263+
finished = true;
264+
}
265+
266+
expanded.Add(new Beam(tokens, beam.LogProbability + logProbability, finished));
267+
}
268+
}
269+
270+
beams = expanded
271+
.OrderByDescending(NormalizedScore)
272+
.Take(beamWidth)
273+
.ToList();
274+
}
275+
276+
var best = beams.OrderByDescending(NormalizedScore).First();
277+
return (best.Tokens, best.Finished ? ChatFinishReason.Stop : ChatFinishReason.Length);
278+
}
279+
280+
private static double NormalizedScore(Beam beam) => beam.LogProbability / Math.Max(1, beam.Tokens.Count);
281+
282+
private static double[] LogSoftmax(Vector<T> logits, IReadOnlyCollection<int>? allowed)
283+
{
284+
var count = logits.Length;
285+
bool[]? allowedMask = null;
286+
if (allowed is not null)
287+
{
288+
allowedMask = new bool[count];
289+
foreach (var id in allowed)
290+
{
291+
if (id >= 0 && id < count)
292+
{
293+
allowedMask[id] = true;
294+
}
295+
}
296+
}
297+
298+
var scores = new double[count];
299+
var max = double.NegativeInfinity;
300+
for (var i = 0; i < count; i++)
301+
{
302+
var value = allowedMask is null || allowedMask[i] ? Convert.ToDouble(logits[i]) : double.NegativeInfinity;
303+
scores[i] = value;
304+
if (value > max)
305+
{
306+
max = value;
307+
}
308+
}
309+
310+
var sumExp = 0.0;
311+
for (var i = 0; i < count; i++)
312+
{
313+
if (!double.IsNegativeInfinity(scores[i]))
314+
{
315+
sumExp += Math.Exp(scores[i] - max);
316+
}
317+
}
318+
319+
var logSumExp = max + Math.Log(sumExp);
320+
for (var i = 0; i < count; i++)
321+
{
322+
scores[i] = double.IsNegativeInfinity(scores[i]) ? double.NegativeInfinity : scores[i] - logSumExp;
323+
}
324+
325+
return scores;
326+
}
327+
328+
private static IEnumerable<(int TokenId, double LogProbability)> TopTokens(double[] logProbabilities, int k)
329+
{
330+
return Enumerable.Range(0, logProbabilities.Length)
331+
.Where(i => !double.IsNegativeInfinity(logProbabilities[i]))
332+
.OrderByDescending(i => logProbabilities[i])
333+
.Take(k)
334+
.Select(i => (i, logProbabilities[i]));
335+
}
336+
337+
private sealed class Beam
338+
{
339+
public Beam(List<int> tokens, double logProbability, bool finished)
340+
{
341+
Tokens = tokens;
342+
LogProbability = logProbability;
343+
Finished = finished;
344+
}
345+
346+
public List<int> Tokens { get; }
347+
348+
public double LogProbability { get; }
349+
350+
public bool Finished { get; }
351+
}
352+
197353
private static IReadOnlyList<string>? ResolveStopSequences(ChatOptions? options)
198354
{
199355
var stops = options?.StopSequences;

src/Agentic/Models/Local/LocalEngineOptions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,13 @@ public sealed class LocalEngineOptions
4040
/// vocabulary) at the logits rather than relying on prompting.
4141
/// </summary>
4242
public ITokenConstraint? Constraint { get; set; }
43+
44+
/// <summary>
45+
/// Gets or sets the beam width for beam-search decoding. <c>null</c> or a value &lt;= 1 uses ordinary
46+
/// token-by-token sampling/greedy decoding. A value &gt; 1 explores that many hypotheses in parallel and
47+
/// returns the highest-scoring (length-normalized) completion — deterministic, and typically higher
48+
/// quality than greedy for short structured outputs. Beam search applies to non-streaming
49+
/// <see cref="LocalEngineChatClient{T}.GetResponseAsync"/>; streaming always decodes token-by-token.
50+
/// </summary>
51+
public int? BeamWidth { get; set; }
4352
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Threading.Tasks;
5+
using AiDotNet.Agentic.Models;
6+
using AiDotNet.Agentic.Models.Local;
7+
using AiDotNet.LinearAlgebra;
8+
using Xunit;
9+
10+
namespace AiDotNetTests.UnitTests.Agentic.Local
11+
{
12+
public class BeamSearchTests
13+
{
14+
// Vocabulary: 0=EOS, 1=A, 2=B, 3=C. The model's next-token logits depend on the last token, set up
15+
// so that the locally-best first token (A) leads to a worse overall sequence than B -> C.
16+
private static TransitionModel BuildModel() => new(
17+
vocabularySize: 4,
18+
transitions: new Dictionary<int, double[]>
19+
{
20+
[0] = new[] { 0.1, 2.0, 1.9, 0.1 }, // start (prompt's last token): A slightly beats B
21+
[1] = new[] { 2.0, 0.1, 0.1, 0.1 }, // after A: EOS (A is a short, lower-total path)
22+
[2] = new[] { 0.1, 0.1, 0.1, 5.0 }, // after B: C is almost certain
23+
[3] = new[] { 5.0, 0.1, 0.1, 0.1 }, // after C: EOS
24+
});
25+
26+
private static MapTokenizer BuildTokenizer() =>
27+
new(eos: 0, ("A", 1), ("B", 2), ("C", 3));
28+
29+
[Fact(Timeout = 60000)]
30+
public async Task Greedy_TakesLocallyBestToken()
31+
{
32+
var engine = new LocalEngineChatClient<double>(BuildModel(), BuildTokenizer(), options: new LocalEngineOptions
33+
{
34+
Sampling = new LocalSamplingOptions { Temperature = 0.0 }, // greedy, beam width 1
35+
});
36+
37+
var response = await engine.GetResponseAsync(new[] { ChatMessage.User("go") });
38+
39+
Assert.Equal("A", response.Text);
40+
}
41+
42+
[Fact(Timeout = 60000)]
43+
public async Task BeamSearch_FindsHigherProbabilitySequence()
44+
{
45+
var engine = new LocalEngineChatClient<double>(BuildModel(), BuildTokenizer(), options: new LocalEngineOptions
46+
{
47+
BeamWidth = 2,
48+
});
49+
50+
var response = await engine.GetResponseAsync(new[] { ChatMessage.User("go") });
51+
52+
// Beam search explores B as well as A and discovers the globally better B -> C completion.
53+
Assert.Equal("BC", response.Text);
54+
Assert.Equal(ChatFinishReason.Stop, response.FinishReason);
55+
}
56+
57+
[Fact(Timeout = 60000)]
58+
public async Task BeamSearch_RespectsConstraint()
59+
{
60+
// Force the grammar A -> B -> (terminal) even though the model would prefer other paths.
61+
var constraint = new FiniteStateTokenConstraint(
62+
start: new[] { 1 },
63+
transitions: new Dictionary<int, IReadOnlyCollection<int>>
64+
{
65+
[1] = new[] { 2 },
66+
[2] = Array.Empty<int>(),
67+
});
68+
69+
var engine = new LocalEngineChatClient<double>(BuildModel(), BuildTokenizer(), options: new LocalEngineOptions
70+
{
71+
BeamWidth = 3,
72+
Constraint = constraint,
73+
});
74+
75+
var response = await engine.GetResponseAsync(new[] { ChatMessage.User("go") });
76+
77+
Assert.Equal("AB", response.Text);
78+
Assert.Equal(ChatFinishReason.Stop, response.FinishReason);
79+
}
80+
81+
// ---- Test doubles ----
82+
83+
private sealed class TransitionModel : ICausalLanguageModel<double>
84+
{
85+
private readonly Dictionary<int, double[]> _transitions;
86+
private readonly double[] _default;
87+
88+
public TransitionModel(int vocabularySize, Dictionary<int, double[]> transitions)
89+
{
90+
VocabularySize = vocabularySize;
91+
_transitions = transitions;
92+
_default = new double[vocabularySize];
93+
_default[0] = 5.0; // unknown state -> prefer EOS
94+
}
95+
96+
public int VocabularySize { get; }
97+
98+
public Vector<double> NextTokenLogits(IReadOnlyList<int> tokenIds)
99+
{
100+
var last = tokenIds[tokenIds.Count - 1];
101+
var logits = _transitions.TryGetValue(last, out var row) ? row : _default;
102+
return new Vector<double>((double[])logits.Clone());
103+
}
104+
}
105+
106+
private sealed class MapTokenizer : IGenerationTokenizer
107+
{
108+
private readonly Dictionary<string, int> _wordToId = new(StringComparer.Ordinal);
109+
private readonly Dictionary<int, string> _idToWord = new();
110+
111+
public MapTokenizer(int eos, params (string Word, int Id)[] entries)
112+
{
113+
EosTokenId = eos;
114+
_idToWord[eos] = string.Empty;
115+
foreach (var (word, id) in entries)
116+
{
117+
_wordToId[word] = id;
118+
_idToWord[id] = word;
119+
}
120+
}
121+
122+
public int EosTokenId { get; }
123+
124+
public IReadOnlyList<int> Encode(string text)
125+
{
126+
var ids = text
127+
.Split(new[] { ' ', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries)
128+
.Select(w => _wordToId.TryGetValue(w, out var id) ? id : EosTokenId)
129+
.ToList();
130+
if (ids.Count == 0)
131+
{
132+
ids.Add(EosTokenId);
133+
}
134+
135+
return ids;
136+
}
137+
138+
public string Decode(IReadOnlyList<int> tokenIds) =>
139+
string.Concat(tokenIds.Select(id => _idToWord.TryGetValue(id, out var w) ? w : string.Empty));
140+
}
141+
}
142+
}

0 commit comments

Comments
 (0)