NoulQuestion
Go · package github.com/muthuishere/toolnexus/golang · SPEC §8B · golang/classifier.go
type Question interface{ /* sealed: NoulQuestion | ChoiceQuestion | ScoreQuestion */ }
type NoulCriteria struct { True string False string}
type NoulQuestion struct { Instructions string Criteria *NoulCriteria // optional; nil omits the field entirely}
type ChoiceQuestion struct { Instructions string Criteria map[string]string // option id -> what picking it would MEAN; 1..255}
type ScoreQuestion struct { Instructions string Criteria []string // 2..10 ORDERED levels; the order IS the numbering}
func ChoiceOver(instructions string, items map[string]string) ChoiceQuestionThe three question types, the criteria each one needs, and the limits enforced client-side before
the request. Question is a sealed interface — the three structs are the only implementations, so
a switch over them is exhaustive and a fourth shape cannot be smuggled in.
Questions are independent. One answer is never context for another; a backend that cannot
guarantee that reports Calibrated: false.
When to use it
Section titled “When to use it”| Type | You get back | Reach for it when |
|---|---|---|
NoulQuestion |
one number in 0..1 — no confidence; the number is the answer |
is this true, is this from an untrusted source |
ChoiceQuestion |
one of your named options, a probability for every option, a confidence | routing to a skill, a tool, an agent, a move |
ScoreQuestion |
a number against an ordered rubric of 2–10 levels (1.21 is a real answer), with a probability per level |
risk, urgency, severity |
Declare them up front and pass them all in one Evaluate call: many questions, one round trip,
one state ingest.
Why this and not the alternative
Section titled “Why this and not the alternative”criteria, per type
Section titled “criteria, per type”- noul — optional.
Criteriais a pointer because absent and empty are different values and both are preserved on the wire:nilomitscriteriaentirely, while a non-nil pointer to a zero value sends both labels as empty strings. - choice — required,
id → what picking that option would MEAN. Not a label, not the id again: a sentence naming the consequence. - score — required, an ordered slice. Index 0 is level 0. Nothing sorts it; a “sort everything” canonicaliser would silently renumber the rubric, which is why arrays are never reordered in the canonical request.
The client-side limits
Section titled “The client-side limits”| Rule | Constant | Error |
|---|---|---|
a choice needs 1..255 options |
MaxChoiceOptions = 255 |
classifier: question "<key>": a choice needs 1..255 options, got N |
a score needs 2..10 ordered levels |
MinScoreLevels = 2, MaxScoreLevels = 10 |
classifier: question "<key>": a score needs 2..10 ordered levels, got N |
Both are enforced before the request: the error names the offending question key and the limit,
and no request is sent — you find out faster and more legibly than from the backend’s own
400 "Too many choices.", which is still surfaced intact if it arrives. Keys are walked in sorted
order, so the same malformed set always names the same key first. An empty questions map, or a
nil question value, is an error for the same reason.
Examples
Section titled “Examples”1. The smallest useful call — all three types in one round trip
Section titled “1. The smallest useful call — all three types in one round trip”package main
import ( "context" "fmt" "log"
toolnexus "github.com/muthuishere/toolnexus/golang")
func main() { state := "my card was charged twice for the annual plan and the second charge was not refunded"
questions := map[string]toolnexus.Question{ "wants_money_back": toolnexus.NoulQuestion{ Instructions: "Is the customer asking for money to be returned?", Criteria: &toolnexus.NoulCriteria{ True: "the customer wants a payment reversed or credited back", False: "the customer wants something other than money returned", }, }, "department": toolnexus.ChoiceQuestion{ Instructions: "Which desk should own this ticket?", Criteria: map[string]string{ "billing": "own it here when the problem is money that moved: a duplicate charge, a refund owed", "technical": "own it here when the problem is the product itself: a login that fails, a feature that errors", }, }, "urgency": toolnexus.ScoreQuestion{ Instructions: "How fast does this ticket need a human?", Criteria: []string{ "the customer is working normally and is waiting on an answer", "the customer is inconvenienced and will chase if nobody replies today", "the customer is blocked from working right now", }, }, }
recorded := `{"model":"typesafe/jev-1.13","calibrated":true, "answers":{ "wants_money_back":{"type":"noul","noul":0.99}, "department":{"type":"choice","choice":"billing", "probabilities":{"billing":0.97,"technical":0.03},"confidence":0.94}, "urgency":{"type":"score","score":0.49, "legend":{"0":"working normally","1":"inconvenienced","2":"blocked"}, "probabilities":{"0":0.52,"1":0.48,"2":0},"confidence":0.27}}, "usage":{"input_tokens":516,"output_tokens":72}}`
judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{ Style: toolnexus.StyleStatic, Model: "typesafe/jev-1.13", Decisions: []toolnexus.RecordedDecision{{State: state, Questions: questions, Response: []byte(recorded)}}, }) if err != nil { log.Fatal(err) }
d, err := judge.Evaluate(context.Background(), state, questions) if err != nil { log.Fatal(err) }
dept, err := d.Choice("department") if err != nil { log.Fatal(err) } urg, err := d.Score("urgency") if err != nil { log.Fatal(err) }
fmt.Printf("ok: desk=%s urgency=%v (a score may fall BETWEEN levels)\n", dept.Choice, urg.Score)}2. The realistic case — the limits fail before the request
Section titled “2. The realistic case — the limits fail before the request”package main
import ( "context" "fmt" "log" "strings"
toolnexus "github.com/muthuishere/toolnexus/golang")
func main() { // A custom backend that records whether it was ever reached. reached := false judge, err := toolnexus.CreateClassifier(toolnexus.ClassifierOptions{ Style: toolnexus.StyleCustom, Evaluate: func(_ context.Context, _ any, _ map[string]toolnexus.Question) (toolnexus.Decision, error) { reached = true return toolnexus.Decision{}, nil }, }) if err != nil { log.Fatal(err) }
levels := make([]string, 11) // 11 > MaxScoreLevels for i := range levels { levels[i] = fmt.Sprintf("level %d", i) }
_, err = judge.Evaluate(context.Background(), "anything", map[string]toolnexus.Question{ "urgency": toolnexus.ScoreQuestion{Instructions: "how urgent?", Criteria: levels}, }) if err == nil { log.Fatal("expected a client-side limit error") } // The error names the offending question KEY and the limit. if !strings.Contains(err.Error(), `"urgency"`) || !strings.Contains(err.Error(), "2..10") { log.Fatalf("unhelpful error: %v", err) } if reached { log.Fatal("no request may be sent once a limit fails") }
// A choice is capped at 255 options, checked the same way. tooMany := make(map[string]string, 256) for i := 0; i < 256; i++ { tooMany[fmt.Sprintf("opt%03d", i)] = "pick this when it is the right one" } _, err = judge.Evaluate(context.Background(), "anything", map[string]toolnexus.Question{ "route": toolnexus.ChoiceQuestion{Instructions: "which?", Criteria: tooMany}, }) if err == nil || !strings.Contains(err.Error(), "1..255") { log.Fatalf("expected the 255-option cap, got %v", err) }
fmt.Println("ok: both limits refused before any request")}3. The full surface — ChoiceOver, and criteria absent vs empty
Section titled “3. The full surface — ChoiceOver, and criteria absent vs empty”ChoiceOver builds a ChoiceQuestion from any (name, description) pairs — a tool roster, a
skill list, an A2A agent card’s skills. It copies the map, so later mutation of the caller’s map
cannot reach the question.
package main
import ( "fmt" "log"
toolnexus "github.com/muthuishere/toolnexus/golang")
func main() { q := toolnexus.ChoiceOver("Which tool answers this turn?", map[string]string{ "search_docs": "use this when the answer is written down somewhere in the product docs", "run_query": "use this when the answer has to be computed from the live database", })
// A noul's criteria: nil omits the field, a non-nil zero value sends both labels // empty. Absent and empty are DIFFERENT values, and both survive the wire. absent := toolnexus.NoulQuestion{Instructions: "is this urgent?"} empty := toolnexus.NoulQuestion{Instructions: "is this urgent?", Criteria: &toolnexus.NoulCriteria{}}
withAbsent, err := toolnexus.CanonicalRequest("jev-latest", map[string]toolnexus.Question{"u": absent}) if err != nil { log.Fatal(err) } withEmpty, err := toolnexus.CanonicalRequest("jev-latest", map[string]toolnexus.Question{"u": empty}) if err != nil { log.Fatal(err) } if string(withAbsent) == string(withEmpty) { log.Fatal("absent criteria must not collapse into empty criteria") }
// The canonical request covers `model` + `questions` only: keys sorted recursively // in ASCII order, arrays never reordered. `state` is outside the claim and is sent // verbatim, because numbers do not canonicalise across seven runtimes. canon, err := toolnexus.CanonicalRequest("jev-latest", map[string]toolnexus.Question{"tool": q}) if err != nil { log.Fatal(err) }
fmt.Println("ok:", string(canon)[:40], "…")}See also
Section titled “See also”CreateClassifier— A sibling of the client: pre-declared typed questions in, calibrated answers out — no messages, no tool calling, no loop.Decision— One answer per question under the caller’s own keys, read through typed accessors that fail loudly rather than hand back a zero.- The encoding obligation — what a described option is worth, measured.
- Typed decisions · Backends · Cookbook