diff --git a/decision/clef.go b/decision/clef.go
new file mode 100644
index 000000000..31d0b419a
--- /dev/null
+++ b/decision/clef.go
@@ -0,0 +1,246 @@
+package decision
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "maps"
+ "math"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/ollama/ollama/llm"
+)
+
+// encodeClef preserves the reference encoder's JSON and segment boundaries,
+// retaining its choice order for the shared answer decoder.
+func encodeClef(req Request, c *Compiled) error {
+ state, err := clefContent(req.State)
+ if err != nil {
+ return fmt.Errorf("state: %w", err)
+ }
+ input := llm.ScoreRequest{Images: req.Images, ImagePosition: 1}
+ add := func(text string) [2]int {
+ n := len(input.Segments)
+ input.Segments = append(input.Segments, text)
+ return [2]int{n, n + 1}
+ }
+ add("<|im_start|>system\nRead the complete state and schema. Decide every field jointly. Each answer must be exactly one of that field's allowed options.<|im_end|>\n<|im_start|>user\nSTATE:\n")
+ add(state)
+ add("\n\nSCHEMA FIELDS:\n")
+ for name, q := range req.Questions.All() {
+ f, err := clefField(name, q)
+ if err != nil {
+ return fmt.Errorf("question %q: %w", name, err)
+ }
+ add(fmt.Sprintf("\nFIELD %d\nID: %s\nTYPE: %s\nINSTRUCTION: ", len(c.fields)+1, name, q.Type))
+ field := llm.ScoreField{Question: add(f.Description)}
+ switch q.Type {
+ case "choice":
+ field.Type = 1
+ case "score":
+ field.Type = 2
+ }
+ add("\nALLOWED OPTIONS:\n")
+ for j, option := range f.Choices {
+ add(fmt.Sprintf("OPTION %d: ", j+1))
+ semantics := map[string]any{"option_id": fmt.Sprint(option.Value)}
+ if option.Description != nil {
+ semantics["description"] = option.Description
+ }
+ raw, err := clefJSON(semantics)
+ if err != nil {
+ return err
+ }
+ field.Options = append(field.Options, add(raw))
+ add("\n")
+ }
+ add("END FIELD\n")
+ input.Fields = append(input.Fields, field)
+ c.fields = append(c.fields, f)
+ }
+ add("\n<|im_end|>\n<|im_start|>assistant\n\n\n\n\nJOINT SCHEMA DECISIONS:")
+ c.Request = input
+ return nil
+}
+
+func clefField(name string, q Question) (compiledField, error) {
+ f := compiledField{Field: Field{Name: name}, typ: q.Type}
+ if strings.TrimSpace(name) == "" {
+ return f, fmt.Errorf("field name must not be empty")
+ }
+ f.Description = name
+ if raw := bytes.TrimSpace(q.Instructions); len(raw) > 0 && string(raw) != "null" && string(raw) != `""` {
+ var err error
+ f.Description, err = clefContent(raw)
+ if err != nil {
+ return f, fmt.Errorf("instructions: %w", err)
+ }
+ }
+ var criteria any
+ if len(q.Criteria) > 0 {
+ var err error
+ criteria, err = clefValue(q.Criteria)
+ if err != nil {
+ return f, fmt.Errorf("criteria: %w", err)
+ }
+ }
+ add := func(value, description any) {
+ f.Choices = append(f.Choices, Choice{Value: value, Description: description})
+ }
+ switch q.Type {
+ case "noul":
+ descriptions := map[string]any{"true": "The proposition is true or the answer is yes.", "false": "The proposition is false or the answer is no."}
+ if criteria != nil {
+ values, ok := criteria.(map[string]any)
+ if !ok {
+ return f, fmt.Errorf("noul criteria must be an object")
+ }
+ for key, value := range values {
+ if key != "true" && key != "false" {
+ return f, fmt.Errorf("unknown noul criterion %q", key)
+ }
+ descriptions[key] = value
+ }
+ }
+ add(true, descriptions["true"])
+ add(false, descriptions["false"])
+ case "choice":
+ values, ok := criteria.(map[string]any)
+ if !ok {
+ return f, fmt.Errorf("choice criteria must be an object")
+ }
+ for _, key := range slices.Sorted(maps.Keys(values)) {
+ if strings.TrimSpace(key) == "" {
+ return f, fmt.Errorf("choice keys must not be empty")
+ }
+ add(key, values[key])
+ }
+ case "score":
+ values, ok := criteria.([]any)
+ if !ok {
+ return f, fmt.Errorf("score criteria must be an array")
+ }
+ for i, value := range values {
+ add(strconv.Itoa(i), value)
+ }
+ default:
+ return f, fmt.Errorf("type must be choice, noul, or score")
+ }
+ if len(f.Choices) < 2 || len(f.Choices) > 26 {
+ return f, fmt.Errorf("criteria must contain 2–26 candidates")
+ }
+ return f, nil
+}
+
+func clefValue(raw json.RawMessage) (any, error) {
+ if !json.Valid(raw) {
+ return nil, fmt.Errorf("must be a JSON value")
+ }
+ var value any
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.UseNumber()
+ err := dec.Decode(&value)
+ return value, err
+}
+
+func clefContent(raw json.RawMessage) (string, error) {
+ value, err := clefValue(raw)
+ if err != nil {
+ return "", err
+ }
+ if s, ok := value.(string); ok {
+ return s, nil
+ }
+ return clefJSON(value)
+}
+
+// clefJSON matches Python's json.dumps(ensure_ascii=False, sort_keys=True,
+// separators=(",", ":")), including its integer/float distinction.
+// encoding/json sorts map keys, but its number and Unicode escaping rules
+// differ from the reference and would change the model's input tokens.
+func clefJSON(value any) (string, error) {
+ switch v := value.(type) {
+ case nil:
+ return "null", nil
+ case bool:
+ return strconv.FormatBool(v), nil
+ case string:
+ var b strings.Builder
+ b.WriteByte('"')
+ for _, r := range v {
+ switch r {
+ case '"', '\\':
+ b.WriteByte('\\')
+ b.WriteRune(r)
+ case '\b':
+ b.WriteString(`\b`)
+ case '\f':
+ b.WriteString(`\f`)
+ case '\n':
+ b.WriteString(`\n`)
+ case '\r':
+ b.WriteString(`\r`)
+ case '\t':
+ b.WriteString(`\t`)
+ default:
+ if r < 0x20 {
+ fmt.Fprintf(&b, `\u%04x`, r)
+ } else {
+ b.WriteRune(r)
+ }
+ }
+ }
+ b.WriteByte('"')
+ return b.String(), nil
+ case json.Number:
+ if !strings.ContainsAny(string(v), ".eE") {
+ if v == "-0" {
+ return "0", nil
+ }
+ return string(v), nil
+ }
+ n, err := v.Float64()
+ if math.IsInf(n, 0) {
+ if n < 0 {
+ return "-Infinity", nil
+ }
+ return "Infinity", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ if a := math.Abs(n); a >= 1e16 || (a != 0 && a < 1e-4) {
+ return strconv.FormatFloat(n, 'e', -1, 64), nil
+ }
+ s := strconv.FormatFloat(n, 'f', -1, 64)
+ if !strings.ContainsRune(s, '.') {
+ s += ".0"
+ }
+ return s, nil
+ case []any:
+ parts := make([]string, len(v))
+ for i, child := range v {
+ text, err := clefJSON(child)
+ if err != nil {
+ return "", err
+ }
+ parts[i] = text
+ }
+ return "[" + strings.Join(parts, ",") + "]", nil
+ case map[string]any:
+ parts := make([]string, 0, len(v))
+ for _, key := range slices.Sorted(maps.Keys(v)) {
+ name, _ := clefJSON(key)
+ text, err := clefJSON(v[key])
+ if err != nil {
+ return "", err
+ }
+ parts = append(parts, name+":"+text)
+ }
+ return "{" + strings.Join(parts, ",") + "}", nil
+ default:
+ return "", fmt.Errorf("unsupported JSON value %T", value)
+ }
+}
diff --git a/decision/clef_test.go b/decision/clef_test.go
new file mode 100644
index 000000000..891cf323e
--- /dev/null
+++ b/decision/clef_test.go
@@ -0,0 +1,184 @@
+package decision
+
+import (
+ "encoding/json"
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/ollama/ollama/api"
+ "github.com/ollama/ollama/llm"
+)
+
+func TestCompileClef(t *testing.T) {
+ var req Request
+ if err := json.Unmarshal([]byte(`{"model":"renamed-model","state":{"z":2,"a":"café & "},"questions":{"team":{"type":"choice","instructions":"Route it","criteria":{"z":null,"a":"First"}},"yes":{"type":"noul","instructions":{"z":2,"a":1}},"rating":{"type":"score","instructions":"Rate it","criteria":["Low","High"]}}}`), &req); err != nil {
+ t.Fatal(err)
+ }
+ c, err := CompileWithEncoder(req, "clef")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Render(func([]api.Message) (string, error) { t.Fatal("Clef must not use a chat template"); return "", nil }); err != nil {
+ t.Fatal(err)
+ }
+ input := c.Request
+ if len(input.Fields) != 3 || len(c.Request.Rows) != 0 {
+ t.Fatal("expected one joint request")
+ }
+ if input.Segments[1] != `{"a":"café & ","z":2}` {
+ t.Fatalf("state = %s", input.Segments[1])
+ }
+ option := func(f, i int) string {
+ return strings.Join(input.Segments[input.Fields[f].Options[i][0]:input.Fields[f].Options[i][1]], "")
+ }
+ if option(0, 0) != `{"description":"First","option_id":"a"}` || option(0, 1) != `{"option_id":"z"}` {
+ t.Fatalf("choice ordering / null semantics changed: %s, %s", option(0, 0), option(0, 1))
+ }
+ if option(1, 0) != `{"description":"The proposition is true or the answer is yes.","option_id":"true"}` {
+ t.Fatal(option(1, 0))
+ }
+ if input.Fields[0].Type != 1 || input.Fields[1].Type != 0 || input.Fields[2].Type != 2 {
+ t.Fatal("incorrect type ids")
+ }
+ prompt := strings.Join(input.Segments, "")
+ if !strings.HasSuffix(prompt, "\n\n\n\nJOINT SCHEMA DECISIONS:") || !strings.Contains(prompt, "INSTRUCTION: {\"a\":1,\"z\":2}\nALLOWED OPTIONS:\n") {
+ t.Fatal(prompt)
+ }
+ response, err := c.Answer(req.Model, llm.ScoreResponse{Logits: [][]float32{{2, 0}, {2, 0}, {0, 2}}, InputTokens: 99})
+ if err != nil {
+ t.Fatal(err)
+ }
+ a, _ := response.Answers.Get("team")
+ choice := a.(ChoiceAnswer)
+ n, _ := response.Answers.Get("yes")
+ yes := n.(NoulAnswer)
+ if choice.Choice != "a" || math.Abs(choice.Confidence-0.4729346589968384) > 1e-8 || yes.Noul < 0.88 || response.Usage.OutputTokens != 0 {
+ t.Fatalf("incorrect joint answer: %+v %+v", choice, yes)
+ }
+}
+
+func TestCompileClefImages(t *testing.T) {
+ req := testRequest(t)
+ req.Images = []api.ImageData{[]byte("first image"), []byte("second image")}
+ c, err := CompileWithEncoder(req, "clef")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Request.ImagePosition != 1 || len(c.Request.Images) != 2 || string(c.Request.Images[1]) != "second image" {
+ t.Fatalf("images were not preserved before state: %+v", c.Request)
+ }
+ if _, err := Compile(req); err == nil {
+ t.Fatal("candidate scoring silently ignored images")
+ }
+}
+
+func TestClefCanonicalJSON(t *testing.T) {
+ for _, tt := range []struct{ input, want string }{
+ {`{"v":1e6}`, `{"v":1000000.0}`},
+ {`{"v":-0}`, `{"v":0}`},
+ {`{"v":1e-5}`, `{"v":1e-05}`},
+ {`[1.0,-0.0,1e15,1e16,1e-4,1e-999,1e999,-1e999]`, `[1.0,-0.0,1000000000000000.0,1e+16,0.0001,0.0,Infinity,-Infinity]`},
+ {`{"z":9007199254740993,"a":[true,false,null]}`, `{"a":[true,false,null],"z":9007199254740993}`},
+ {`{"v":"\u2028\u2029"}`, "{\"v\":\"
\"}"},
+ {`{"v":"\\u2028\\u2029"}`, `{"v":"\\u2028\\u2029"}`},
+ {`{"v":"\\\u2028"}`, "{\"v\":\"\\\\
\"}"},
+ {`{"v":"\u0000\b\f\n\r\t\"\\\u007f<>&café"}`, "{\"v\":\"\\u0000\\b\\f\\n\\r\\t\\\"\\\\\x7f<>&café\"}"},
+ {`null`, `null`},
+ {`true`, `true`},
+ {`"plain text"`, `plain text`},
+ } {
+ t.Run(tt.input, func(t *testing.T) {
+ got, err := clefContent(json.RawMessage(tt.input))
+ if err != nil || got != tt.want {
+ t.Fatalf("got %q, %v; want %q", got, err, tt.want)
+ }
+ })
+ }
+ for _, input := range []string{"", `{`, `null true`} {
+ if _, err := clefContent(json.RawMessage(input)); err == nil {
+ t.Fatalf("accepted invalid JSON %q", input)
+ }
+ }
+}
+
+func TestClefSchema(t *testing.T) {
+ var req Request
+ if err := json.Unmarshal([]byte(`{"model":"clef","state":null,"questions":{"team":{"type":"choice","criteria":{"z":null,"a":{"text":"First","weight":1e6}}},"yes":{"type":"noul","instructions":null,"criteria":{"true":null}},"rating":{"type":"score","instructions":"","criteria":[null,{"text":"High"}]}}}`), &req); err != nil {
+ t.Fatal(err)
+ }
+ c, err := CompileWithEncoder(req, "clef")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.Request.Segments[1] != "null" {
+ t.Fatal("lost null state")
+ }
+ for i, name := range []string{"team", "yes", "rating"} {
+ if got := c.Request.Segments[c.Request.Fields[i].Question[0]]; got != name {
+ t.Fatalf("fallback instructions = %q, want %q", got, name)
+ }
+ }
+ for _, tt := range []struct {
+ field, option int
+ want string
+ }{
+ {0, 0, `{"description":{"text":"First","weight":1000000.0},"option_id":"a"}`},
+ {0, 1, `{"option_id":"z"}`},
+ {1, 0, `{"option_id":"true"}`},
+ {2, 0, `{"option_id":"0"}`},
+ {2, 1, `{"description":{"text":"High"},"option_id":"1"}`},
+ } {
+ span := c.Request.Fields[tt.field].Options[tt.option]
+ if got := c.Request.Segments[span[0]]; got != tt.want {
+ t.Fatalf("option = %q, want %q", got, tt.want)
+ }
+ }
+ answer, err := c.Answer(req.Model, llm.ScoreResponse{Logits: [][]float32{{2, 0}, {2, 0}, {0, 2}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ rating, _ := answer.Answers.Get("rating")
+ legend, err := json.Marshal(rating.(ScoreAnswer).Legend)
+ if err != nil || string(legend) != `{"0":null,"1":{"text":"High"}}` {
+ t.Fatalf("legend = %s, %v", legend, err)
+ }
+ if _, err := Compile(req); err == nil {
+ t.Fatal("broadened the candidate-scoring schema")
+ }
+}
+
+func TestClefValidation(t *testing.T) {
+ for _, raw := range []string{
+ `{}`, `{"model":"clef","state":null,"questions":{}}`,
+ `{"model":"clef","questions":{"q":{"type":"noul"}}}`,
+ `{"model":"clef","state":null,"questions":{"":{"type":"noul"}}}`,
+ `{"model":"clef","state":null,"questions":{"q":{"type":"other"}}}`,
+ `{"model":"clef","state":null,"questions":{"q":{"type":"choice","criteria":{"a":null}}}}`,
+ `{"model":"clef","state":null,"questions":{"q":{"type":"score","criteria":{}}}}`,
+ `{"model":"clef","state":null,"questions":{"q":{"type":"noul","criteria":[true,false]}}}`,
+ `{"model":"clef","state":null,"questions":{"q":{"type":"noul","criteria":{"yes":null}}}}`,
+ } {
+ var req Request
+ if err := json.Unmarshal([]byte(raw), &req); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := CompileWithEncoder(req, "clef"); err == nil {
+ t.Fatalf("accepted invalid request %s", raw)
+ }
+ }
+}
+
+func TestDecisionRejectsVideo(t *testing.T) {
+ for _, encoding := range []string{"", "clef"} {
+ req := testRequest(t)
+ req.Videos = []json.RawMessage{json.RawMessage(`"video.mp4"`)}
+ if _, err := CompileWithEncoder(req, encoding); err == nil || !strings.Contains(err.Error(), "video") {
+ t.Fatalf("encoding %q silently accepted video: %v", encoding, err)
+ }
+ req.Videos = nil
+ if _, err := CompileWithEncoder(req, encoding); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
diff --git a/decision/systemone.go b/decision/systemone.go
index 8f07da7e2..0fb4279ba 100644
--- a/decision/systemone.go
+++ b/decision/systemone.go
@@ -27,13 +27,37 @@ type Compiled struct {
messages [][]api.Message
}
+// Compile validates the request and prepares candidate scoring for Nimble and Tev.
func Compile(req Request) (*Compiled, error) {
+ return CompileWithEncoder(req, "")
+}
+
+// CompileWithEncoder validates the request and encodes it using the named input format.
+// An empty encoding uses the candidate-scoring format shared by Nimble and Tev.
+func CompileWithEncoder(req Request, encoding string) (*Compiled, error) {
if strings.TrimSpace(req.Model) == "" {
return nil, fmt.Errorf("model is required")
}
if req.Questions.Len() < 1 || req.Questions.Len() > 64 {
return nil, fmt.Errorf("questions must contain 1–64 fields")
}
+ if len(req.Videos) > 0 {
+ return nil, fmt.Errorf("video inputs are not supported")
+ }
+ switch encoding {
+ case "":
+ if len(req.Images) > 0 {
+ return nil, fmt.Errorf("this decision model does not support images")
+ }
+ case "clef":
+ c := &Compiled{}
+ if err := encodeClef(req, c); err != nil {
+ return nil, err
+ }
+ return c, nil
+ default:
+ return nil, fmt.Errorf("unsupported decision encoding %q", encoding)
+ }
context, err := content(req.State)
if err != nil {
return nil, fmt.Errorf("state: %w", err)
@@ -206,7 +230,11 @@ func (c *Compiled) Answer(model string, result llm.ScoreResponse) (Response, err
}
}
if f.typ == "noul" {
- response.Answers.Set(f.Name, NoulAnswer{Type: f.typ, Noul: p[1]})
+ for j, choice := range f.Choices {
+ if choice.Value == true {
+ response.Answers.Set(f.Name, NoulAnswer{Type: f.typ, Noul: p[j]})
+ }
+ }
continue
}
probabilities := &Probabilities{}
diff --git a/decision/systemone_test.go b/decision/systemone_test.go
index 3e6a16b0e..663a091c7 100644
--- a/decision/systemone_test.go
+++ b/decision/systemone_test.go
@@ -90,6 +90,12 @@ func TestCompile(t *testing.T) {
}
}
+func TestCompileUnsupportedEncoding(t *testing.T) {
+ if _, err := CompileWithEncoder(testRequest(t), "unknown"); err == nil {
+ t.Fatal("accepted unsupported decision encoding")
+ }
+}
+
func TestStructuredStateFrames(t *testing.T) {
req := testRequest(t)
req.State = json.RawMessage(`{"frames":["first","second"],"position":7}`)
@@ -207,8 +213,10 @@ func TestInvalidRequests(t *testing.T) {
q, _ := req.Questions.Get("urgency")
q.Criteria = json.RawMessage(`["x"` + strings.Repeat(`,"x"`, 26) + `]`)
req.Questions.Set("urgency", q)
- if _, err := Compile(req); err == nil {
- t.Fatal("accepted 27 candidates")
+ for _, encoding := range []string{"", "clef"} {
+ if _, err := CompileWithEncoder(req, encoding); err == nil {
+ t.Fatalf("encoding %q accepted 27 candidates", encoding)
+ }
}
}
diff --git a/decision/types.go b/decision/types.go
index a3dfb6481..9b09fbfdd 100644
--- a/decision/types.go
+++ b/decision/types.go
@@ -12,10 +12,12 @@ import (
type Questions = orderedmap.Map[string, Question]
type Request struct {
- Model string `json:"model"`
- State json.RawMessage `json:"state"`
- Questions *Questions `json:"questions"`
- KeepAlive *api.Duration `json:"keep_alive,omitempty"`
+ Model string `json:"model"`
+ State json.RawMessage `json:"state"`
+ Images []api.ImageData `json:"images,omitempty"`
+ Videos []json.RawMessage `json:"videos,omitempty"`
+ Questions *Questions `json:"questions"`
+ KeepAlive *api.Duration `json:"keep_alive,omitempty"`
}
type Question struct {
@@ -28,7 +30,7 @@ type Question struct {
type Choice struct {
Code string `json:"code"`
Value any `json:"value"`
- Description string `json:"description"`
+ Description any `json:"description"`
}
// Field is a question's schema as presented to the model.
@@ -41,7 +43,7 @@ type Field struct {
type (
Answers = orderedmap.Map[string, any]
Probabilities = orderedmap.Map[string, float64]
- Legend = orderedmap.Map[string, string]
+ Legend = orderedmap.Map[string, any]
)
type Response struct {
diff --git a/docs/api/introduction.mdx b/docs/api/introduction.mdx
index 0b42a6b30..2d7a73d4b 100644
--- a/docs/api/introduction.mdx
+++ b/docs/api/introduction.mdx
@@ -22,7 +22,7 @@ The Anthropic client appends `/v1/messages` to its base URL.
Cloud requests need an API key. Local requests do not. To use cloud models through your local server, [sign in to Ollama](/api/authentication#signing-in).
-Creating or deleting models requires a local Ollama server. For supported API features, see [OpenAI compatibility](/api/openai-compatibility) and [Anthropic compatibility](/api/anthropic-compatibility).
+Creating or deleting models requires a local Ollama server. [System One](/capabilities/decision) also requires a local server and is not yet available on Ollama Cloud. For supported API features, see [OpenAI compatibility](/api/openai-compatibility) and [Anthropic compatibility](/api/anthropic-compatibility).
## Ollama API example
diff --git a/docs/capabilities/decision.mdx b/docs/capabilities/decision.mdx
new file mode 100644
index 000000000..5ba05daf2
--- /dev/null
+++ b/docs/capabilities/decision.mdx
@@ -0,0 +1,113 @@
+---
+title: Decision
+description: Classify text, estimate yes/no probabilities, and score ordered criteria.
+---
+
+
+Currently available locally in Ollama.
+
+
+Use System One to classify text, answer yes/no questions, or score text against a rubric.
+
+## Setup
+
+Start [Ollama](/quickstart) v0.35.0 or later, then download [Nimble](https://ollama.com/library/nimble):
+
+```shell setup.sh
+ollama pull nimble
+```
+
+Local requests do not need an API key.
+
+## Choose a label
+
+Put your input in `state` and describe the possible answers in `criteria`:
+
+```shell choice.sh
+curl http://localhost:11434/v1/systemone \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "model": "nimble",
+ "state": "Our checkout has returned 500 errors since 9am.",
+ "questions": {
+ "label": {
+ "type": "choice",
+ "instructions": "Which label fits this ticket?",
+ "criteria": {
+ "billing": "Payments and refunds",
+ "bug": "Software errors",
+ "account": "Login and account access"
+ }
+ }
+ }
+ }'
+```
+
+Example response:
+
+```json
+{
+ "model": "nimble",
+ "answers": {
+ "label": {
+ "type": "choice",
+ "choice": "bug",
+ "probabilities": {"billing": 0.0125, "bug": 0.9781, "account": 0.0093},
+ "confidence": 0.8906
+ }
+ },
+ "usage": {"input_tokens": 174, "output_tokens": 1}
+}
+```
+
+## Question types
+
+| Type | Criteria | Result |
+| --- | --- | --- |
+| `choice` | 2–26 named options with descriptions | The most likely option and each option's probability |
+| `noul` | Descriptions for `"false"` and `"true"` | The probability of `true`, from 0 to 1 |
+| `score` | 2–26 descriptions, ordered from lowest to highest | The probability-weighted average of the zero-based levels |
+
+For the urgency criteria below:
+
+```text
+score = 0 × P(Routine) + 1 × P(Soon) + 2 × P(Immediate)
+```
+
+For `choice` and `score`, `confidence` measures how strongly the model favors one answer over the others, from 0 to 1. A higher value does not guarantee the answer is correct.
+
+## Ask multiple questions
+
+Combine question types in one request. Here, both questions use the same ticket:
+
+```shell multiple.sh
+curl http://localhost:11434/v1/systemone \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "model": "nimble",
+ "state": {"ticket": "I was charged twice. Please refund the extra payment."},
+ "questions": {
+ "refund": {
+ "type": "noul",
+ "instructions": "Is the customer requesting a refund?",
+ "criteria": {
+ "false": "No refund is requested",
+ "true": "The customer requests a refund"
+ }
+ },
+ "urgency": {
+ "type": "score",
+ "instructions": "How urgently does this ticket need a response?",
+ "criteria": [
+ "Routine: no time pressure",
+ "Soon: a customer is inconvenienced",
+ "Immediate: a critical service is unavailable"
+ ]
+ }
+ }
+ }'
+```
+
+This returns a refund probability of `0.9989` and an urgency score of `0.8308` on the 0–2 scale (rounded). Results can vary with the model and configuration.
+
+See the [API reference](/api/systemone) for request fields, limits, errors, and token usage.
diff --git a/docs/docs.json b/docs/docs.json
index 1f7524517..44a655bcd 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -128,6 +128,7 @@
"/capabilities/streaming",
"/capabilities/thinking",
"/capabilities/structured-outputs",
+ "/capabilities/decision",
"/capabilities/vision",
"/capabilities/embeddings",
"/capabilities/tool-calling",
@@ -249,6 +250,7 @@
"POST /api/generate",
"POST /api/chat",
"POST /api/embed",
+ "POST /v1/systemone",
"GET /api/tags",
"GET /api/ps",
"POST /api/show",
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 874a196a0..60ea409ef 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -828,6 +828,195 @@ components:
items:
$ref: "#/components/schemas/TokenLogprob"
description: Most likely tokens and their log probabilities at this position
+ SystemOneContent:
+ description: A nonempty string, or an object or array serialized as JSON text. Not interpreted as chat messages or multimodal input.
+ oneOf:
+ - type: string
+ pattern: '\S'
+ - type: object
+ additionalProperties: true
+ - type: array
+ items: {}
+ SystemOneChoiceQuestion:
+ title: Choice
+ type: object
+ required: [type, instructions, criteria]
+ properties:
+ type:
+ type: string
+ enum: [choice]
+ instructions:
+ $ref: "#/components/schemas/SystemOneContent"
+ criteria:
+ type: object
+ description: Option keys mapped to descriptions. A null description uses the key itself. Keys must not be blank; ties follow the model's option order.
+ minProperties: 2
+ maxProperties: 26
+ propertyNames:
+ pattern: '\S'
+ additionalProperties:
+ type: [string, "null"]
+ SystemOneNoulQuestion:
+ title: Noul
+ type: object
+ required: [type, instructions]
+ properties:
+ type:
+ type: string
+ enum: [noul]
+ instructions:
+ $ref: "#/components/schemas/SystemOneContent"
+ criteria:
+ type: object
+ description: Optional descriptions for the two outcomes. Omitted entries use No and Yes.
+ properties:
+ "false":
+ type: string
+ default: "No"
+ "true":
+ type: string
+ default: "Yes"
+ additionalProperties: false
+ SystemOneScoreQuestion:
+ title: Score
+ type: object
+ required: [type, instructions, criteria]
+ properties:
+ type:
+ type: string
+ enum: [score]
+ instructions:
+ $ref: "#/components/schemas/SystemOneContent"
+ criteria:
+ type: array
+ description: Descriptions ordered from the lowest score (index 0) to the highest. Defines a scale from 0 to the number of criteria minus 1.
+ minItems: 2
+ maxItems: 26
+ items:
+ type: string
+ SystemOneRequest:
+ type: object
+ required: [model, state, questions]
+ properties:
+ model:
+ type: string
+ pattern: '\S'
+ description: Local model trained for System One, such as nimble. Requires compatible GGUF weights and a scoring-capable runner; cloud and MLX/Safetensors models are not supported.
+ state:
+ $ref: "#/components/schemas/SystemOneContent"
+ images:
+ type: array
+ description: Base64-encoded images shared by all questions, in request order. Requires Clef or Clef Flash with vision weights. URLs and data URLs are not supported.
+ items:
+ type: string
+ format: byte
+ questions:
+ type: object
+ description: Named questions about the shared state. Answers are not passed to later questions.
+ minProperties: 1
+ maxProperties: 64
+ propertyNames:
+ pattern: '\S'
+ additionalProperties:
+ oneOf:
+ - $ref: "#/components/schemas/SystemOneChoiceQuestion"
+ - $ref: "#/components/schemas/SystemOneNoulQuestion"
+ - $ref: "#/components/schemas/SystemOneScoreQuestion"
+ keep_alive:
+ oneOf:
+ - type: string
+ - type: number
+ description: How long to keep the model loaded after the request, as a duration string (such as 5m) or seconds. Zero unloads after the request; a negative value keeps it loaded. Defaults to the server's keep-alive setting (5m unless configured otherwise).
+ SystemOneProbabilities:
+ type: object
+ description: Probabilities normalized over the supplied candidates, summing to 1 subject to floating-point precision.
+ additionalProperties:
+ type: number
+ minimum: 0
+ maximum: 1
+ SystemOneConfidence:
+ type: number
+ minimum: 0
+ maximum: 1
+ description: Distribution concentration, calculated as 1 - H(p) / ln(N), where H(p) is entropy and N is the candidate count. Zero means uniform probabilities; values near 1 mean one candidate dominates. Not calibrated correctness.
+ SystemOneChoiceAnswer:
+ title: Choice
+ type: object
+ required: [type, choice, probabilities, confidence]
+ properties:
+ type:
+ type: string
+ enum: [choice]
+ choice:
+ type: string
+ description: Option key with the highest probability. Ties follow the model's option order.
+ probabilities:
+ $ref: "#/components/schemas/SystemOneProbabilities"
+ confidence:
+ $ref: "#/components/schemas/SystemOneConfidence"
+ SystemOneNoulAnswer:
+ title: Noul
+ type: object
+ required: [type, noul]
+ properties:
+ type:
+ type: string
+ enum: [noul]
+ noul:
+ type: number
+ minimum: 0
+ maximum: 1
+ description: Probability of true among the false and true candidates. This is a number, not a Boolean.
+ SystemOneScoreAnswer:
+ title: Score
+ type: object
+ required: [type, score, legend, probabilities, confidence]
+ properties:
+ type:
+ type: string
+ enum: [score]
+ score:
+ type: number
+ minimum: 0
+ maximum: 25
+ description: Probability-weighted average of the zero-based criterion indices, from 0 to the number of criteria minus 1. Not rounded to a level or normalized to 0–1.
+ legend:
+ type: object
+ description: Zero-based indices as string keys mapped to the criterion descriptions.
+ additionalProperties:
+ type: string
+ probabilities:
+ $ref: "#/components/schemas/SystemOneProbabilities"
+ description: Probabilities keyed by zero-based criterion indices as strings.
+ confidence:
+ $ref: "#/components/schemas/SystemOneConfidence"
+ SystemOneResponse:
+ type: object
+ required: [model, answers, usage]
+ properties:
+ model:
+ type: string
+ description: Model name from the request.
+ answers:
+ type: object
+ description: Answers keyed by the question names in the request.
+ additionalProperties:
+ oneOf:
+ - $ref: "#/components/schemas/SystemOneChoiceAnswer"
+ - $ref: "#/components/schemas/SystemOneNoulAnswer"
+ - $ref: "#/components/schemas/SystemOneScoreAnswer"
+ usage:
+ type: object
+ required: [input_tokens, output_tokens]
+ properties:
+ input_tokens:
+ type: integer
+ minimum: 0
+ description: Total evaluated input tokens, including image positions. Shared context is counted again when the model scores questions separately.
+ output_tokens:
+ type: integer
+ minimum: 0
+ description: Tokens generated internally for scoring, including prefix preparation and retries. May exceed the question count; not the length of the JSON response.
ErrorResponse:
type: object
properties:
@@ -835,6 +1024,109 @@ components:
type: string
description: Error message describing what went wrong
paths:
+ /v1/systemone:
+ post:
+ summary: Answer typed questions
+ description: |
+ Answer choice, yes/no, and scoring questions with a local System One model. Requires Ollama v0.35.0 or later. See the [decision guide](/capabilities/decision) for examples.
+ operationId: systemone
+ x-mint:
+ href: /api/systemone
+ metadata:
+ title: System One
+ sidebarTitle: System One
+ content: |
+
+ Currently available locally in Ollama.
+
+
+ - Returns one JSON response; streaming, video, tools, and generation controls are not supported.
+ - Requests without images must fit within 64 KiB; requests with images must fit within 32 MiB, including base64 and JSON. The complete input must fit the loaded context window. Nimble and Tev also require two token positions for scoring. Input is never truncated.
+ x-codeSamples:
+ - lang: bash
+ label: Choose a label
+ source: |
+ curl http://localhost:11434/v1/systemone \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "model": "nimble",
+ "state": "Our checkout has returned 500 errors since 9am.",
+ "questions": {
+ "label": {
+ "type": "choice",
+ "instructions": "Which label fits this ticket?",
+ "criteria": {
+ "billing": "Payments and refunds",
+ "bug": "Software errors",
+ "account": "Login and account access"
+ }
+ }
+ }
+ }'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SystemOneRequest"
+ example:
+ model: nimble
+ state: Our checkout has returned 500 errors since 9am.
+ questions:
+ label:
+ type: choice
+ instructions: Which label fits this ticket?
+ criteria:
+ billing: Payments and refunds
+ bug: Software errors
+ account: Login and account access
+ responses:
+ "200":
+ description: Answers and token usage for all questions. Example probabilities and confidence are rounded to four decimal places; results and usage can vary with the model and server configuration.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SystemOneResponse"
+ example:
+ model: nimble
+ answers:
+ label:
+ type: choice
+ choice: bug
+ probabilities:
+ billing: 0.0125
+ bug: 0.9781
+ account: 0.0093
+ confidence: 0.8906
+ usage:
+ input_tokens: 174
+ output_tokens: 1
+ "400":
+ description: Invalid request, unsupported model or runner, or a rendered prompt that exceeds the loaded context. Cloud models are rejected.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ "404":
+ description: Local model not found. Download the model before making a request.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ "413":
+ description: Request body exceeds 64 KiB without images or 32 MiB with images.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ example:
+ error: request body must not exceed 64 KiB without images
+ "500":
+ description: Model loading, rendering, or scoring failed.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
/api/generate:
post:
summary: Generate a response
diff --git a/llama/clef/clef.cpp b/llama/clef/clef.cpp
new file mode 100644
index 000000000..f5ad2e6fe
--- /dev/null
+++ b/llama/clef/clef.cpp
@@ -0,0 +1,375 @@
+// Clef joint schema head, ported from Cloudflare's Apache-2.0 reference.
+// https://huggingface.co/Cloudflare/clef/blob/main/joint_schema_model.py
+#include "clef.h"
+#include "ggml-backend.h"
+#include "ggml.h"
+#include "gguf.h"
+#include
+#include
+#include
+#include
+#include