fix(ask): judge every question in one request
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

The state dominates a System One request and each question is scored in
isolation against it, so sending context choices separately only re-sent
the state per request and added round trips. A live ask carried 22,957
input tokens over two requests for one question; one request carries it
once. Drops the now unused AskOptions.MaxParallel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 08:00:44 +02:00
co-authored by Claude Fable 5.1
parent 8fa9f20192
commit 347ac9d7a6
2 changed files with 15 additions and 33 deletions
+11 -29
View File
@@ -20,7 +20,7 @@ public enum ContextScope
Documents,
}
public sealed record AskOptions(int K = 6, int DocChars = 12_000, int StateChars = 60_000, int MaxParallel = 8);
public sealed record AskOptions(int K = 6, int DocChars = 12_000, int StateChars = 60_000);
public sealed record AskRequest(IReadOnlyList<string> Queries, IReadOnlyDictionary<string, AskQuestion>? Questions, AskOptions Options);
@@ -52,7 +52,7 @@ public sealed record AskResult(
/// <summary>
/// wiki_ask.py as a use case: rank every query, unite the top notes into one context (per-document
/// and total budget), expand context choices over the paragraph or document ids plus NONE, add the
/// covered gate, judge (every context choice in its own request, the rest batched), and attach the
/// covered gate, judge every question in one request, and attach the
/// answering paragraphs' text to the results. Judgements and quotations, never prose.
/// </summary>
public sealed class AskUseCase(RankUseCase rank, IDocumentCatalog catalog, IChunker chunker)
@@ -69,7 +69,7 @@ public sealed class AskUseCase(RankUseCase rank, IDocumentCatalog catalog, IChun
var questions = request.Questions ?? DefaultQuestions(request.Queries);
var (expanded, scopes) = Expand(questions, context, request.Queries, judge.MaxChoiceOptions);
var (answers, usage) = await JudgeInParallelAsync(judge, context, expanded, scopes, request.Options.MaxParallel, ct);
var (answers, usage) = await JudgeAsync(judge, context, expanded, ct);
var resolved = Resolve(answers, scopes, context);
double? covered = answers.TryGetValue(CoveredId, out var c) && c is Judgement.Noul n ? Math.Round(n.Probability, 3) : null;
return new AskResult(request.Queries, context, meta, resolved, covered, usage, judge.ModelId, Stopwatch.GetElapsedTime(started));
@@ -183,9 +183,12 @@ public sealed class AskUseCase(RankUseCase rank, IDocumentCatalog catalog, IChun
return (expanded, scopes);
}
private static async Task<(IReadOnlyDictionary<string, Judgement> Answers, JudgeUsage Usage)> JudgeInParallelAsync(
IJudge judge, IReadOnlyList<ContextDocument> context, IReadOnlyDictionary<string, TypedQuestion> questions,
IReadOnlyDictionary<string, ContextScope> scopes, int maxParallel, CancellationToken ct)
/// <summary>
/// One request for every question: the state dominates the request, and System One scores each
/// question in isolation against it, so splitting only re-sends the state and adds round trips.
/// </summary>
private static async Task<(IReadOnlyDictionary<string, Judgement> Answers, JudgeUsage Usage)> JudgeAsync(
IJudge judge, IReadOnlyList<ContextDocument> context, IReadOnlyDictionary<string, TypedQuestion> questions, CancellationToken ct)
{
var state = new
{
@@ -196,29 +199,8 @@ public sealed class AskUseCase(RankUseCase rank, IDocumentCatalog catalog, IChun
paragraphs = d.Paragraphs.Select(p => new { id = p.Id, heading = p.Heading, text = p.Text }),
}),
};
var groups = new List<Dictionary<string, TypedQuestion>>();
foreach (var (id, q) in questions.Where(kv => scopes.ContainsKey(kv.Key)))
groups.Add(new Dictionary<string, TypedQuestion> { [id] = q });
var rest = questions.Where(kv => !scopes.ContainsKey(kv.Key)).ToDictionary(kv => kv.Key, kv => kv.Value);
if (rest.Count > 0) groups.Add(rest);
using var gate = new SemaphoreSlim(Math.Max(1, maxParallel));
var tasks = groups.Select(async g =>
{
await gate.WaitAsync(ct);
try { return await judge.JudgeAsync(new JudgeRequest(state, g), ct); }
finally { gate.Release(); }
}).ToList();
var results = await Task.WhenAll(tasks);
var answers = new Dictionary<string, Judgement>();
var usage = JudgeUsage.None;
foreach (var r in results)
{
foreach (var (id, a) in r.Answers) answers[id] = a;
usage = usage.Add(r.Usage);
}
return (answers, usage);
var result = await judge.JudgeAsync(new JudgeRequest(state, questions), ct);
return (result.Answers, result.Usage);
}
private static IReadOnlyDictionary<string, AskAnswer> Resolve(
@@ -81,10 +81,10 @@ public class AskUseCaseTests
Assert.Equal("udm-dns#p1", where.Top[0].Id);
Assert.Contains("answers every query", where.Top[0].Text);
Assert.Equal("UDM intercepts DNS", where.Top[0].Title);
// the context choice went alone, the noul questions together
Assert.Equal(2, _judge.Requests.Count);
Assert.Contains(_judge.Requests, r => r.Questions.Count == 1 && r.Questions.ContainsKey("where_1"));
Assert.Equal(2, result.Usage.Requests);
// every question rides in the one request that carries the state
var sentToJudge = Assert.Single(_judge.Requests);
Assert.Equal(3, sentToJudge.Questions.Count);
Assert.Equal(1, result.Usage.Requests);
Assert.Equal("fake-judge", result.JudgeModel);
}