Files
relata/internal/analysis/validate.go
T
2026-08-11 10:40:10 +01:00

315 lines
9.9 KiB
Go

package analysis
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"unicode"
"unicode/utf8"
)
// Rejection reasons, used as metric labels and audit detail. They are a closed
// set so that cardinality stays bounded.
const (
ReasonNotJSON = "not_json"
ReasonUnknownField = "unknown_field"
ReasonTrailingData = "trailing_data"
ReasonTooLarge = "too_large"
ReasonBadEnum = "bad_enum"
ReasonUnknownRef = "unknown_reference"
ReasonMissingField = "missing_field"
ReasonOutOfRange = "out_of_range"
ReasonTooManyItems = "too_many_items"
ReasonEmpty = "empty_response"
ReasonNotGrounded = "not_grounded"
)
// InvalidOutputError describes model output that failed validation.
//
// It is deliberately specific: a permanently invalid response must not be
// retried, and knowing why lets the caller decide that without guessing.
type InvalidOutputError struct {
Task string
Reason string
Detail string
// Sample is a short, truncated excerpt of the offending output, kept for
// diagnostics. It is bounded because model output is untrusted.
Sample string
}
func (e *InvalidOutputError) Error() string {
return fmt.Sprintf("model output rejected for task %s (%s): %s", e.Task, e.Reason, e.Detail)
}
// Is lets callers match with errors.Is(err, domain.ErrModelInvalid) via Unwrap.
func (e *InvalidOutputError) Unwrap() error { return errInvalidOutput }
var errInvalidOutput = errors.New("invalid model output")
// ErrInvalidOutput is the sentinel all validation failures unwrap to.
var ErrInvalidOutput = errInvalidOutput
// Reject builds an InvalidOutputError with a bounded sample.
func Reject(task, reason, detail, raw string) *InvalidOutputError {
return &InvalidOutputError{
Task: task,
Reason: reason,
Detail: detail,
Sample: sample(raw, 300),
}
}
// maxModelOutputBytes bounds any single structured response before decoding.
const maxModelOutputBytes = 256 << 10
// DecodeStrict parses raw model output into dst.
//
// It is strict on purpose:
// - unknown fields are an error, so a model that invents structure is caught
// rather than silently ignored;
// - trailing content after the JSON value is an error, so a model that
// appends commentary or a second object cannot smuggle it past;
// - the payload is size-bounded before parsing.
//
// No regular expressions are used to find or repair JSON. If the response is
// not a single well-formed JSON value of the expected shape, it is rejected.
func DecodeStrict(task string, raw string, dst any) error {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return Reject(task, ReasonEmpty, "the model returned an empty response", raw)
}
if len(trimmed) > maxModelOutputBytes {
return Reject(task, ReasonTooLarge,
fmt.Sprintf("response of %d bytes exceeds the %d byte limit", len(trimmed), maxModelOutputBytes), raw)
}
if !utf8.ValidString(trimmed) {
return Reject(task, ReasonNotJSON, "response is not valid UTF-8", raw)
}
// Some models wrap JSON in a markdown fence despite being asked not to.
// Unwrapping a complete, well-formed fence is a structural operation on a
// delimiter, not a regex-based repair of malformed JSON: if what remains is
// not valid JSON it is still rejected below.
trimmed = unwrapFence(trimmed)
dec := json.NewDecoder(strings.NewReader(trimmed))
dec.DisallowUnknownFields()
dec.UseNumber()
if err := dec.Decode(dst); err != nil {
reason := ReasonNotJSON
if strings.Contains(err.Error(), "unknown field") {
reason = ReasonUnknownField
}
return Reject(task, reason, err.Error(), raw)
}
if _, err := dec.Token(); err != io.EOF {
return Reject(task, ReasonTrailingData,
"the response contains additional content after the JSON value", raw)
}
return nil
}
// unwrapFence removes a single complete ```/```json fence around the payload.
func unwrapFence(s string) string {
if !strings.HasPrefix(s, "```") {
return s
}
// Drop the opening fence line.
nl := strings.IndexByte(s, '\n')
if nl < 0 {
return s
}
rest := s[nl+1:]
end := strings.LastIndex(rest, "```")
if end < 0 {
return s
}
return strings.TrimSpace(rest[:end])
}
// sample truncates untrusted text to n bytes on a rune boundary, for logging.
func sample(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n] + "…"
}
// ---------------------------------------------------------------------------
// Reference and value checking
// ---------------------------------------------------------------------------
// RefSet is the set of identifiers a model was actually shown. Any ID in the
// response that is not in this set is a hallucination and invalidates the
// record that references it.
type RefSet map[string]struct{}
// NewRefSet builds a reference set from the IDs supplied to the model.
func NewRefSet(ids ...string) RefSet {
s := make(RefSet, len(ids))
for _, id := range ids {
if id != "" {
s[id] = struct{}{}
}
}
return s
}
// Add registers an additional valid identifier.
func (s RefSet) Add(id string) {
if id != "" {
s[id] = struct{}{}
}
}
// Has reports whether id was shown to the model.
func (s RefSet) Has(id string) bool {
_, ok := s[id]
return ok
}
// CheckEnum validates that value is one of allowed, returning a rejection
// otherwise. Enum checking is what stops a model inventing a relationship type.
func CheckEnum[T ~string](task, field string, value T, allowed []T, raw string) error {
for _, a := range allowed {
if a == value {
return nil
}
}
names := make([]string, len(allowed))
for i, a := range allowed {
names[i] = string(a)
}
return Reject(task, ReasonBadEnum,
fmt.Sprintf("%s=%q is not one of: %s", field, string(value), strings.Join(names, ", ")), raw)
}
// CheckRange validates a numeric field.
func CheckRange(task, field string, v, lo, hi float64, raw string) error {
if v < lo || v > hi {
return Reject(task, ReasonOutOfRange,
fmt.Sprintf("%s=%v is outside the range %v..%v", field, v, lo, hi), raw)
}
return nil
}
// CheckCount enforces an upper bound on array length before the items are used.
func CheckCount(task, field string, n, maxN int, raw string) error {
if n > maxN {
return Reject(task, ReasonTooManyItems,
fmt.Sprintf("%s contains %d entries, more than the %d permitted", field, n, maxN), raw)
}
return nil
}
// CleanText normalises a model-supplied string for storage: control characters
// removed, whitespace collapsed, length bounded.
//
// Model output is rendered as escaped HTML everywhere in Relata, so this is not
// the XSS defence; it exists so that stored text stays displayable and bounded.
func CleanText(s string, maxLen int) string {
s = strings.Map(func(r rune) rune {
switch {
case r == '\n' || r == '\t':
return ' '
case r < 0x20 || r == 0x7f:
return -1
}
return r
}, s)
s = strings.Join(strings.Fields(s), " ")
if maxLen > 0 && len(s) > maxLen {
s = sample(s, maxLen)
}
return s
}
// CompactJSON re-encodes v without whitespace, used to embed schemas in
// requests.
func CompactJSON(v any) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
}
var out bytes.Buffer
if err := json.Compact(&out, b); err != nil {
return nil, err
}
return out.Bytes(), nil
}
// ---------------------------------------------------------------------------
// Degenerate output
// ---------------------------------------------------------------------------
// ReasonDegenerate marks output that is structurally valid but carries no
// content — the model filled the schema without answering.
const ReasonDegenerate = "degenerate_content"
// schemaEchoes are tokens a struggling model emits in place of real content,
// usually by echoing a field name or a boolean from the schema it was given.
var schemaEchoes = map[string]bool{
"answer": true, "sufficient": true, "insufficient": true, "statement": true,
"citations": true, "conflicts": true, "rationale": true, "name": true,
"type": true, "quote": true, "subject": true, "value": true, "text": true,
"true": true, "false": true, "null": true, "nil": true, "none": true,
"n/a": true, "na": true, "unknown": true, "undefined": true, "string": true,
"todo": true, "example": true, "...": true, "-": true,
}
// Degenerate reports whether s is too empty to be usable content.
//
// A schema constrains a response's *shape*, not its substance: a model can
// satisfy every structural rule and still return the word "sufficient" where an
// answer belongs. That is invalid output, and treating it as an answer would
// present the model's failure to the user as knowledge.
func Degenerate(s string, minLength int) bool {
t := strings.ToLower(strings.TrimSpace(s))
t = strings.Trim(t, ".,;:!?\"'`*_ ")
if t == "" {
return true
}
if schemaEchoes[t] {
return true
}
// The length floor applies only to output carrying no digits.
//
// "3" is a complete answer to "how many depots?", while "x" is a
// placeholder — the difference is that a number is substance and a lone
// letter is not. Without this, the floor that catches "x" also throws away
// correct numeric answers.
if !strings.ContainsFunc(t, unicode.IsDigit) && utf8.RuneCountInString(t) < minLength {
return true
}
// Nothing but punctuation — "...", "-", "??" and the like.
//
// Digits count as content. An earlier version of this required a letter,
// which rejected "£42,000" as an answer to "What is the total project
// cost?" — a correct answer, and the best possible one. Terse factual
// answers are exactly what a factual question should produce, so the only
// thing worth rejecting here is output with no substance at all.
for _, r := range t {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return false
}
}
return true
}
// RejectDegenerate builds the rejection for empty-but-well-formed output.
func RejectDegenerate(task, field, got string) *InvalidOutputError {
return Reject(task, ReasonDegenerate,
fmt.Sprintf("%s contains no usable content — the model satisfied the schema without answering", field),
got)
}