Add semantic configuration resume evidence
This commit is contained in:
86
internal/stage/semantic_fingerprint.go
Normal file
86
internal/stage/semantic_fingerprint.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
// SemanticConfigFingerprinter is implemented by stages whose reusable result
|
||||
// depends on Narratio-observable semantic configuration.
|
||||
type SemanticConfigFingerprinter interface {
|
||||
SemanticConfigFingerprint(env *Env) (manifest.SemanticConfigFingerprint, error)
|
||||
}
|
||||
|
||||
// FingerprintSemanticConfig hashes deterministic JSON for a stage-owned typed
|
||||
// semantic configuration value. Callers must exclude secrets and operational
|
||||
// settings that cannot affect the canonical result.
|
||||
func FingerprintSemanticConfig(version int, value any) (manifest.SemanticConfigFingerprint, error) {
|
||||
record := manifest.SemanticConfigFingerprint{Version: version}
|
||||
if version <= 0 {
|
||||
return record, fmt.Errorf("semantic configuration fingerprint version must be positive")
|
||||
}
|
||||
typeOf := reflect.TypeOf(value)
|
||||
if typeOf == nil {
|
||||
return record, fmt.Errorf("semantic configuration value is nil")
|
||||
}
|
||||
valueOf := reflect.ValueOf(value)
|
||||
if valueOf.Kind() == reflect.Pointer && valueOf.IsNil() {
|
||||
return record, fmt.Errorf("semantic configuration value is nil")
|
||||
}
|
||||
for typeOf.Kind() == reflect.Pointer {
|
||||
typeOf = typeOf.Elem()
|
||||
}
|
||||
if typeOf.Kind() != reflect.Struct {
|
||||
return record, fmt.Errorf("semantic configuration value must be a typed struct, got %s", typeOf.Kind())
|
||||
}
|
||||
if err := validateSemanticConfigType(typeOf, map[reflect.Type]bool{}); err != nil {
|
||||
return record, err
|
||||
}
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return record, fmt.Errorf("encode semantic configuration: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
record.Digest = hex.EncodeToString(digest[:])
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func validateSemanticConfigType(value reflect.Type, visiting map[reflect.Type]bool) error {
|
||||
for value.Kind() == reflect.Pointer {
|
||||
value = value.Elem()
|
||||
}
|
||||
if visiting[value] {
|
||||
return nil
|
||||
}
|
||||
visiting[value] = true
|
||||
defer delete(visiting, value)
|
||||
switch value.Kind() {
|
||||
case reflect.Interface:
|
||||
return fmt.Errorf("semantic configuration structs cannot contain arbitrary interface values")
|
||||
case reflect.Struct:
|
||||
for index := 0; index < value.NumField(); index++ {
|
||||
field := value.Field(index)
|
||||
if field.PkgPath != "" || field.Tag.Get("json") == "-" {
|
||||
continue
|
||||
}
|
||||
if err := validateSemanticConfigType(field.Type, visiting); err != nil {
|
||||
return fmt.Errorf("semantic configuration field %s: %w", field.Name, err)
|
||||
}
|
||||
}
|
||||
case reflect.Slice, reflect.Array:
|
||||
return validateSemanticConfigType(value.Elem(), visiting)
|
||||
case reflect.Map:
|
||||
if value.Key().Kind() != reflect.String {
|
||||
return fmt.Errorf("semantic configuration maps must use string keys")
|
||||
}
|
||||
return validateSemanticConfigType(value.Elem(), visiting)
|
||||
case reflect.Chan, reflect.Func, reflect.UnsafePointer:
|
||||
return fmt.Errorf("semantic configuration contains unsupported %s value", value.Kind())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
61
internal/stage/semantic_fingerprint_test.go
Normal file
61
internal/stage/semantic_fingerprint_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type semanticFingerprintFixture struct {
|
||||
Model string `json:"model"`
|
||||
Modules []string `json:"modules"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func TestFingerprintSemanticConfigIsDeterministicAndTyped(t *testing.T) {
|
||||
value := semanticFingerprintFixture{Model: "quality", Modules: []string{"speaker", "terms"}, Enabled: true}
|
||||
first, err := FingerprintSemanticConfig(2, value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := FingerprintSemanticConfig(2, &value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !first.Equal(second) || first.Version != 2 || len(first.Digest) != 64 || first.Validate() != nil {
|
||||
t.Fatalf("fingerprints = %#v / %#v", first, second)
|
||||
}
|
||||
changed := value
|
||||
changed.Model = "testing"
|
||||
different, err := FingerprintSemanticConfig(2, changed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.Equal(different) {
|
||||
t.Fatalf("changed semantic value retained digest %q", first.Digest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintSemanticConfigRejectsInvalidInputs(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
version int
|
||||
value any
|
||||
want string
|
||||
}{
|
||||
{name: "version", value: semanticFingerprintFixture{}, want: "positive"},
|
||||
{name: "nil", version: 1, value: nil, want: "nil"},
|
||||
{name: "typed nil", version: 1, value: (*semanticFingerprintFixture)(nil), want: "nil"},
|
||||
{name: "untyped map", version: 1, value: map[string]any{"model": "quality"}, want: "typed struct"},
|
||||
{name: "arbitrary field", version: 1, value: struct {
|
||||
Values map[string]any `json:"values"`
|
||||
}{Values: map[string]any{"model": "quality"}}, want: "arbitrary interface"},
|
||||
{name: "slice", version: 1, value: []string{"quality"}, want: "typed struct"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := FingerprintSemanticConfig(test.version, test.value)
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user