From 347ac9d7a61a2e10e23e902323c3f69a74c6c61c Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Fri, 25 Sep 2026 08:00:44 +0200 Subject: [PATCH] fix(ask): judge every question in one request 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 --- src/Wiki.Application/Ask/AskUseCase.cs | 40 +++++-------------- .../Wiki.Application.Tests/AskUseCaseTests.cs | 8 ++-- 2 files changed, 15 insertions(+), 33 deletions(-) diff --git a/src/Wiki.Application/Ask/AskUseCase.cs b/src/Wiki.Application/Ask/AskUseCase.cs index 7de3c36..4779921 100644 --- a/src/Wiki.Application/Ask/AskUseCase.cs +++ b/src/Wiki.Application/Ask/AskUseCase.cs @@ -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 Queries, IReadOnlyDictionary? Questions, AskOptions Options); @@ -52,7 +52,7 @@ public sealed record AskResult( /// /// 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. /// 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 Answers, JudgeUsage Usage)> JudgeInParallelAsync( - IJudge judge, IReadOnlyList context, IReadOnlyDictionary questions, - IReadOnlyDictionary scopes, int maxParallel, CancellationToken ct) + /// + /// 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. + /// + private static async Task<(IReadOnlyDictionary Answers, JudgeUsage Usage)> JudgeAsync( + IJudge judge, IReadOnlyList context, IReadOnlyDictionary 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>(); - foreach (var (id, q) in questions.Where(kv => scopes.ContainsKey(kv.Key))) - groups.Add(new Dictionary { [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(); - 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 Resolve( diff --git a/test/Wiki.Application.Tests/AskUseCaseTests.cs b/test/Wiki.Application.Tests/AskUseCaseTests.cs index fbb7266..6d36c46 100644 --- a/test/Wiki.Application.Tests/AskUseCaseTests.cs +++ b/test/Wiki.Application.Tests/AskUseCaseTests.cs @@ -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); } -- 2.54.0