feat: answers come back under the key that asked for them #21
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/decisions-typed-keys"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The third and last PR of the Decisions series, after #17 (find) and #19 (call). This one reads a decision without writing an id twice or casting, and refuses one that does not answer what was asked. It adds one new
FailureKind,Unanswered, as the owner decided. Enum-backed choices and the Agents tool-call gate remain separate, later work.Typed keys
DecisionQuestionSetis anIReadOnlyDictionary<string, DecisionQuestion>over anOrderedDictionary. It goes out as the same plain map, in the order added, pinned against the dictionary form.Noul/Choice/Scorereturn aDecisionKey<TAnswer>, a sealed record: blank ids are refused, and two keys are equal by id and answer type.Add(id, question)takes a raw or prebuilt question, and a collection initializer works.…Dictionary/…Collectionsuffix. It is suppressed with a justification, asQuantization.Int8's CA1720 is, keeping the name the owner approved.Get(key)throwsKeyNotFoundExceptionorInvalidOperationException, naming what it found instead.TryGet(key, out …)andTryGet<T>(id, out …)are there too; the second is for a question asked without a key, such as a raw one.DecideAsyncandValidateAgainstDecideAsync(request), andDecideAsync(request, route, ct)with a required token as on the raw call, send the request and then runresponse.ValidateAgainst(request.Questions). The check requires:nullanswer counting as none;UnknownDecisionAnswernever answers a noul, choice or score question. A raw question is answered by an answer of its owntypethat the library could read, and its probabilities and confidence are checked; its labels and levels are not modelled, so they are not;NaNcounting as out of range.Answers to questions that were not asked are ignored. The probabilities need not sum to 1 (they are rounded) nor cover every label.
A response that fails the check is a
FailureKind.Unansweredfailure:Decision gen-dec-7 does not answer what was asked: 'team' chose 'sales', which is not one of its labels; 'urgency' has no answer.IsTransientis false, with no status and noDetails. It was billed, and the same request is likely to be answered the same way.Assert.Samepins this forNetwork,RateLimited,ValidationandMalformed.It fails closed. A
Result.Failurehas nowhere to carry the paid response, so the README documents the rawCreateDecisionsAsyncplusValidateAgainstas the way to keep it.FailureKind.Unansweredis appended afterValidation, andIsTransientis unchanged: it lists only transient kinds. The guard test that fails whenever a kind is added without a row in the transience theory caught it at once, and now covers it at 15. 0.3.0 is unreleased andFailureKindis new in it, so the upgrade table gets no row.Helpers
None has a built-in threshold, and none reaches the wire: every answer type round-trips byte for byte with the helpers populated.
NoulAnswer:IsAtLeast(t)andIsAtMost(t), both inclusive.ChoiceAnswer:Ranked()lists labels by probability. On a rounded tie the chosen label ranks first, and the other tied labels keep the response's order.RunnerUpis the most probable label other than the choice, so it is never the choice itself.Marginis how far the choice is ahead of it: 0 on a tie, and null when the probabilities leave out the choice.ScoreAnswer:MostLikelyLevelis the argmax, taking the lowest level on a tie.Docs
DecideAsyncandGet. Both snippets compile; I checked them in a scratch project.DecideAsync's route overload, and the raw call is described as the audit path.Unanswered.IOpenRouterClient.CreateDecisionsAsyncand the extension class now point callers atDecideAsync.Merged with
main#20 merged while this was being written. The branch was never pushed before and had no review, so it was rebased onto
81134bc, with no conflicts. #20's new interface member has a default body, so neitherFixedDecisionsClient(moved intoMocksfrom #19's tests) nor anything else here implements it. The Kind table's order reads correctly with #20'sStalled/Interruptedrows.Tests
81134bc, after #20)DecideTests:Unanswered, sent once with retries on, with the id in the message;DecisionsResponseValidationTests:NaNprobability and score;DecisionQuestionSetTests:GetandTryGet, including missing, mistyped and unreadable answers;DecisionAnswerHelpersTests:Rankedwith two tie orders, one row per tie-break it must tell apart;RunnerUpandMargin, with and without other labels or the choice;MostLikelyLevel, including ties;Coverage: all 121 instrumented lines this PR adds are hit.
Mutation probes. Each was applied to a copy, built and tested, then restored. All 30 were caught:
DecideAsync: the send failure re-created, the check failure reported asMalformed, the id dropped, the check skipped;ValidateAgainst: a null answer counted as an answer, labels, probability keys and level keys unchecked, the score's top or bottom off by one,NaNscores or probabilities in range, confidence unchecked, a raw question accepting an unreadable answer, its type or its ranges unchecked;Getthrowing the wrong exception,TryGetignoring the type;Rankedascending or tie-broken by name,RunnerUpallowed to be the choice,Margin's sign flipped,MostLikelyLeveltaking the highest level on a tie,RunnerUp/MostLikelyLevelon the wire,IsAtLeast/IsAtMostexclusive;Unansweredmade transient.Review. Before this PR opened, three review passes covered the API and its semantics, the docs against actual behaviour, and test adequacy through mutations, and a skeptic tried to refute each finding. 10 were confirmed and are fixed above:
DecideAsyncsucceeded. Now it fails closed.RunnerUpreturned the chosen label itself on a rounded tie.MostLikelyLevelcould name level 5 on a three-level scale.9 findings were rejected, among them null-contract violations that nothing on the wire can produce, and a failure branch missing from the README example, which is a style choice.
Out of scope
gen-declookups. They still need a real key:dotnet run --project samples/OpenRouter.Net.Sample -- decisions.🤖 Generated with Claude Code
Summary
Summary
Coverage
OpenRouter.Net - 96.6%
OpenRouter.Net.Agents - 80%
OpenRouter.Net.Imaging - 81.8%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! Typed keys that carry their answer type, a validator that fails closed, and a runner-up that can never be the choice itself~ Someone wrote this PR while thinking about every rounded corner. I read all 1,541 lines of the diff, the full contents of every changed file plus the converters and
DecisionWirethey lean on — and then I got to work with my probes ♡Verdict: ✅ Looks good to me~
No blockers. Not one. You have no idea how rare that is~ ♪
🔬 What I verified myself (CI is absent for
c830bed, so everything below is local, on this exact head)TreatWarningsAsErrors+AnalysisLevel latest-recommended. The CA1710 suppression is justified inline, exactly likeQuantization.Int8's.DecisionsResponse,DecisionQuestionSet,DecisionKey<T>,ChoiceAnswer,ScoreAnswer,NoulAnswer, the extensions,FailureKindMap). Your "all 121 instrumented lines" claim holds.answer is UnknownDecisionAnswer { Raw.ValueKind: Null }) → 2 RED, exactly the two "a null answer" pins.Ranked()choice-first tie-break removed → 1 RED, exactly the tie test. (The comment in your tie test about two rows not telling apart "as given" from "sorted" — correct and delightfully paranoid~)Countinstead ofCount - 1) → 6 RED across both suites.DecideAsynccheck skipped entirely → 9 RED.MostLikelyLeveltie takes highest instead of lowest → 1 RED, exactly the{"2":0.5,"1":0.5}row.✅ What I liked~
HandleNull => true→UnknownDecisionAnswer { Raw: null token }→ValidateAgainst'sRaw.ValueKindpattern — I traced all three links and the two tests pin both ends. Beautiful chain~Assert.Sameon send failures. Passing the instance through untouched — and pinning it for four kinds — is the kind of promise testers usually forget to make.Assert.Single(handler.Requests)under a 3-attempt retry config. Retry logic untouched, semantics correct — architectural, not incidental.RunnerUp/Marginnull contracts (no other label / choice missing from probabilities) each get their own row, and "no helper reaches the wire" is pinned by byte-for-byte round-trips including runtime-type serialization.[JsonIgnore]on computed properties, verified — not assumed ♡FixedDecisionsClientmoved toMocksinstead of copy-pasted — and it still compiles against #20's new interface member only because that member has a default body, which you checked before relying on it. DRY and honest.FailureKindTestscounts 15 andUnansweredis non-transient — the tripwire you built in an earlier PR caught this addition exactly as designed.CreateDecisionsAsync+ValidateAgainst. The docs match the behavior; I checked every claim against the code.💡 Little ideas (non-blocking)~
ChoiceAnswer.Ranked()tie order /DecisionQuestionSetinsertion order rely on add-onlyDictionaryenumeration being insertion order — de-facto true on every current .NET, unspecified by contract. Your tests pin the behavior, so drift would be caught loudly; nothing to change, just a footnote for the day someone swaps the backing type.Automated review by Jibril · 2026-09-23
CI/CD: absent for head SHA
c830bed· Local checks: build 0w/0e, 1074/1074 pass, 100% line coverage on all changed classes, 6/6 mutation probes directional