package eval import ( "encoding/json" "fmt" "math" "regexp" "strconv" "strings" ) // verdict is how one answer scored, 0 to 1, and why. type verdict struct { score float64 detail string } func pass(detail string) verdict { return verdict{1, detail} } func fail(detail string) verdict { return verdict{0, detail} } var thinkBlock = regexp.MustCompile(`(?s).*?`) // cleanAnswer is what a model said, without a reasoning model's thinking — // what a reader is shown — and without the white space around it. func cleanAnswer(s string) string { s = thinkBlock.ReplaceAllString(s, "") // A reply cut off while still thinking has an opening tag and no close. if i := strings.Index(s, ""); i >= 0 { s = s[:i] } return strings.TrimSpace(s) } // scoreRule scores an answer by a case's rule; a case graded by a judge is // scored elsewhere, since it takes another request. func scoreRule(c *Case, answer string) verdict { fold := func(s string) string { if c.IgnoreCase { return strings.ToLower(s) } return s } switch { case c.Exact != nil: if fold(answer) == fold(strings.TrimSpace(*c.Exact)) { return pass("the answer is exactly that") } return fail(fmt.Sprintf("wanted exactly %q", strings.TrimSpace(*c.Exact))) case len(c.Contains) > 0: var missing []string for _, want := range c.Contains { if !strings.Contains(fold(answer), fold(want)) { missing = append(missing, strconv.Quote(want)) } } if len(missing) == 0 { return pass("has everything asked for") } return fail("has no " + strings.Join(missing, ", ")) case c.re != nil: if c.re.MatchString(answer) { return pass("matches") } return fail("does not match " + c.re.String()) case c.Number != nil: got, ok := lastNumber(answer) if !ok { return fail(fmt.Sprintf("no number in the answer; wanted %v", *c.Number)) } if math.Abs(got-*c.Number) <= c.Tolerance { return pass(fmt.Sprintf("read %v", got)) } want := fmt.Sprintf("%v", *c.Number) if c.Tolerance > 0 { want += fmt.Sprintf(" ± %v", c.Tolerance) } return fail(fmt.Sprintf("read %v, wanted %s", got, want)) case c.schema != nil: v, err := readJSON(answer) if err != nil { return fail(err.Error()) } if why := c.schema.validate(v, ""); why != "" { return fail(why) } return pass("JSON of the shape asked for") } return fail("no rule to score it by") } // number is a number as a model writes one: a sign, thousands separated by // commas, a decimal part, an exponent. var number = regexp.MustCompile(`[-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:[eE][-+]?\d+)?`) // lastNumber is the last number in a text: a model that shows its working // ends with the result, and one that does not says only the result. func lastNumber(s string) (float64, bool) { all := number.FindAllStringIndex(s, -1) if len(all) == 0 { return 0, false } i, j := all[len(all)-1][0], all[len(all)-1][1] // A hyphen after a digit or letter joins (3-5, 2026-10-04); it is not a // minus sign. if (s[i] == '-' || s[i] == '+') && i > 0 && isWordByte(s[i-1]) { i++ } f, err := strconv.ParseFloat(strings.ReplaceAll(s[i:j], ",", ""), 64) return f, err == nil } func isWordByte(b byte) bool { return b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' } var fence = regexp.MustCompile("(?s)```(?:json|JSON)?\\s*(.*?)```") // readJSON reads an answer as JSON: the whole of it, else what is inside a // code fence, else from the first brace or bracket to the last — models // asked for JSON wrap it in a sentence often enough that refusing that would // measure manners rather than the shape. func readJSON(answer string) (any, error) { try := []string{answer} if m := fence.FindStringSubmatch(answer); m != nil { try = append(try, m[1]) } for _, pair := range [][2]string{{"{", "}"}, {"[", "]"}} { i, j := strings.Index(answer, pair[0]), strings.LastIndex(answer, pair[1]) if i >= 0 && j > i { try = append(try, answer[i:j+1]) } } for _, t := range try { var v any if json.Unmarshal([]byte(strings.TrimSpace(t)), &v) == nil { return v, nil } } if answer == "" { return nil, fmt.Errorf("the answer is empty, not JSON") } return nil, fmt.Errorf("the answer is not JSON: %s", clip(answer, 80)) } func clip(s string, n int) string { s = strings.Join(strings.Fields(s), " ") if len(s) <= n { return s } return s[:n-3] + "..." } // judgePrompt asks a model to grade an answer by a rubric. const judgePrompt = "You grade an answer to a question against a rubric. Read the question, the answer " + "and the rubric, then reply with only a JSON object: {\"score\": a number from 0 to 1, " + "\"reason\": \"one short sentence\"}. 1 means the answer meets the rubric fully, 0 not at all." // judgeRequest is what a judge is asked about one answer. func judgeRequest(c *Case, answer string) []Message { var q strings.Builder for _, m := range askedOf(c, "") { if m.Role == "system" { continue } fmt.Fprintf(&q, "%s: %s\n", m.Role, m.Content) } return []Message{ {Role: "system", Content: judgePrompt}, {Role: "user", Content: fmt.Sprintf("Question:\n%s\nAnswer:\n%s\n\nRubric:\n%s", q.String(), answer, c.Judge)}, } } var ( passWord = regexp.MustCompile(`(?i)\b(pass|passed|yes|correct)\b`) failWord = regexp.MustCompile(`(?i)\b(fail|failed|no|incorrect|wrong)\b`) ) // readJudgement reads a judge's reply: a JSON object with a score (or a // pass true or false), else a lone number, else the words pass or fail. func readJudgement(reply string) (verdict, error) { reply = cleanAnswer(reply) if v, err := readJSON(reply); err == nil { if m, ok := v.(map[string]any); ok { reason, _ := m["reason"].(string) if f, ok := m["score"].(float64); ok { return verdict{clamp(f), "the judge: " + clip(reason, 120)}, nil } if s, ok := m["score"].(string); ok { if f, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil { return verdict{clamp(f), "the judge: " + clip(reason, 120)}, nil } } if b, ok := m["pass"].(bool); ok { if b { return pass("the judge: " + clip(reason, 120)), nil } return fail("the judge: " + clip(reason, 120)), nil } } } if f, err := strconv.ParseFloat(strings.TrimSpace(reply), 64); err == nil { return verdict{clamp(f), "the judge gave " + strings.TrimSpace(reply)}, nil } p, f := passWord.MatchString(reply), failWord.MatchString(reply) switch { case p && !f: return pass("the judge: " + clip(reply, 120)), nil case f && !p: return fail("the judge: " + clip(reply, 120)), nil } return verdict{}, fmt.Errorf("the judge's answer could not be read as a score: %s", clip(reply, 120)) } func clamp(f float64) float64 { return math.Max(0, math.Min(1, f)) }