Improve D&D validation reliability
This commit is contained in:
@@ -66,7 +66,7 @@ func TestExtractMapsAndOrdersCombatTurnsBySourcePosition(t *testing.T) {
|
||||
func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
|
||||
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
|
||||
{
|
||||
Actor: " ", TurnKind: "unsupported",
|
||||
Actor: " ", TurnKind: "turn",
|
||||
SourceRefs: []combatSourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||
},
|
||||
}}}
|
||||
@@ -75,7 +75,7 @@ func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
|
||||
t.Fatalf("Extract() error = %v, want nil for candidate values", err)
|
||||
}
|
||||
turn := result.Value.CombatTurns[0]
|
||||
if turn.Actor != " " || turn.TurnKind != "unsupported" {
|
||||
if turn.Actor != " " || turn.TurnKind != "turn" {
|
||||
t.Fatalf("invalid turn fields = %#v, want preserved candidate values", turn)
|
||||
}
|
||||
if turn.SourceRefs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99}) {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func TestLoadResponseSchemaUsesPrivateCombatShape(t *testing.T) {
|
||||
@@ -37,7 +39,7 @@ func TestLoadResponseSchemaUsesPrivateCombatShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *testing.T) {
|
||||
func TestResponseSchemaLeavesNonCategoricalSemanticsToDeterministicValidators(t *testing.T) {
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -45,7 +47,6 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
|
||||
semanticCandidate := validCombatResponse()
|
||||
turn := semanticCandidate["combat_turns"].([]any)[0].(map[string]any)
|
||||
turn["actor"] = ""
|
||||
turn["turn_kind"] = "unsupported"
|
||||
ref := turn["source_refs"].([]any)[0].(map[string]any)
|
||||
ref["start_unit_id"] = 0
|
||||
ref["end_unit_id"] = -1
|
||||
@@ -54,7 +55,7 @@ func TestResponseSchemaLeavesSemanticConstraintsToDeterministicValidators(t *tes
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||
t.Fatalf("private schema rejected validator-owned semantics: %v", err)
|
||||
t.Fatalf("private schema rejected non-categorical validator-owned semantics: %v", err)
|
||||
}
|
||||
turn["source_refs"] = []any{}
|
||||
content, err = json.Marshal(semanticCandidate)
|
||||
@@ -79,6 +80,7 @@ func TestResponseSchemaRetainsStructuralBoundary(t *testing.T) {
|
||||
{name: "wrong actor type", mutate: func(turn map[string]any) { turn["actor"] = 1 }},
|
||||
{name: "unknown field", mutate: func(turn map[string]any) { turn["unexpected"] = true }},
|
||||
{name: "missing source refs", mutate: func(turn map[string]any) { delete(turn, "source_refs") }},
|
||||
{name: "unsupported turn kind", mutate: func(turn map[string]any) { turn["turn_kind"] = "unsupported" }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := validCombatResponse()
|
||||
@@ -94,6 +96,24 @@ func TestResponseSchemaRetainsStructuralBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaAcceptsEverySupportedTurnKind(t *testing.T) {
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, kind := range []string{string(dnd.CombatTurnKindTurn), string(dnd.CombatTurnKindReaction), string(dnd.CombatTurnKindLegendaryAction), string(dnd.CombatTurnKindLairAction), string(dnd.CombatTurnKindOther)} {
|
||||
candidate := validCombatResponse()
|
||||
candidate["combat_turns"].([]any)[0].(map[string]any)["turn_kind"] = kind
|
||||
content, err := json.Marshal(candidate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||
t.Fatalf("supported turn kind %q was rejected: %v", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaJSONIsMutationSafe(t *testing.T) {
|
||||
first, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
|
||||
@@ -59,14 +59,14 @@ func TestExtractMapsEnemyEventsInSourceOrder(t *testing.T) {
|
||||
|
||||
func TestExtractPreservesSemanticCandidatesAndResponseOwnership(t *testing.T) {
|
||||
client := &fakeEnemyEventsLLMClient{response: extractionResponse{Events: []enemyEventResponse{{
|
||||
Name: " ", Kind: "unsupported", SourceRefs: []enemySourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||
Name: " ", Kind: "engaged", SourceRefs: []enemySourceRefResponse{{StartUnitID: 99, EndUnitID: 0}},
|
||||
}}}}
|
||||
result, err := newEnemyExtractor(t, client).Extract(context.Background(), enemyExtractionRequest(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event := result.Value.Events[0]
|
||||
if event.Name != " " || event.Kind != "unsupported" || event.SourceRefs[0] != (source.SourceRef{SourceID: "combat-session", StartUnitID: 99}) {
|
||||
if event.Name != " " || event.Kind != "engaged" || event.SourceRefs[0] != (source.SourceRef{SourceID: "combat-session", StartUnitID: 99}) {
|
||||
t.Fatalf("semantic candidate = %#v", event)
|
||||
}
|
||||
result.Value.Events[0].SourceRefs[0].StartUnitID = 7
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func TestResponseSchemaDefinesPrivateStructuralBoundary(t *testing.T) {
|
||||
@@ -28,13 +30,12 @@ func TestResponseSchemaDefinesPrivateStructuralBoundary(t *testing.T) {
|
||||
semantic := validEnemyResponse()
|
||||
event := semantic["events"].([]any)[0].(map[string]any)
|
||||
event["name"] = ""
|
||||
event["kind"] = "unsupported"
|
||||
ref := event["source_refs"].([]any)[0].(map[string]any)
|
||||
ref["start_unit_id"] = 0
|
||||
ref["end_unit_id"] = -1
|
||||
content, err = json.Marshal(semantic)
|
||||
if err != nil || validateEnemySchema(content, schema.JSONSchema) != nil {
|
||||
t.Fatalf("validator-owned semantics were rejected: %v", err)
|
||||
t.Fatalf("non-categorical validator-owned semantics were rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +47,7 @@ func TestResponseSchemaRejectsInvalidStructure(t *testing.T) {
|
||||
for _, mutate := range []func(map[string]any){
|
||||
func(event map[string]any) { delete(event, "name") },
|
||||
func(event map[string]any) { event["kind"] = 1 },
|
||||
func(event map[string]any) { event["kind"] = "unsupported" },
|
||||
func(event map[string]any) { event["unexpected"] = true },
|
||||
func(event map[string]any) { event["source_refs"].([]any)[0].(map[string]any)["source_id"] = "session" },
|
||||
} {
|
||||
@@ -70,6 +72,24 @@ func TestResponseSchemaRejectsInvalidStructure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaAcceptsEverySupportedKind(t *testing.T) {
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, kind := range []string{string(dnd.EnemyEventKindEngaged), string(dnd.EnemyEventKindKilled), string(dnd.EnemyEventKindFled), string(dnd.EnemyEventKindCaptured), string(dnd.EnemyEventKindIncapacitated)} {
|
||||
candidate := validEnemyResponse()
|
||||
candidate["events"].([]any)[0].(map[string]any)["kind"] = kind
|
||||
content, err := json.Marshal(candidate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateEnemySchema(content, schema.JSONSchema); err != nil {
|
||||
t.Fatalf("supported enemy-event kind %q was rejected: %v", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validEnemyResponse() map[string]any {
|
||||
return map[string]any{"events": []any{map[string]any{
|
||||
"name": "Ashfang", "kind": "engaged", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}},
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
||||
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)},
|
||||
{Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 2)},
|
||||
{Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
||||
{Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)},
|
||||
{Name: "Invalid", Kind: "other", SourceRefs: occurrenceRefs(0, 0)},
|
||||
}}}
|
||||
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
|
||||
req := extractionRequest()
|
||||
@@ -44,7 +44,7 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
||||
dnd.NPCOccurrenceKindCombatAlly,
|
||||
dnd.NPCOccurrenceKindCombatOpponent,
|
||||
dnd.NPCOccurrenceKindOther,
|
||||
"unsupported",
|
||||
dnd.NPCOccurrenceKindOther,
|
||||
}) {
|
||||
t.Fatalf("occurrence kinds = %#v", got)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
||||
if id := result.Value.Occurrences[0].NPCID; id != identity.DeriveID("Mentioned") {
|
||||
t.Fatalf("durable NPC ID = %q, want registry identity", id)
|
||||
}
|
||||
if invalid := result.Value.Occurrences[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
|
||||
if invalid := result.Value.Occurrences[6]; invalid.Name != "Invalid" || invalid.Kind != dnd.NPCOccurrenceKindOther || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
|
||||
t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid)
|
||||
}
|
||||
if len(client.requests) != 1 {
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
||||
@@ -29,7 +31,6 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
||||
semanticCandidate := validOccurrenceResponse()
|
||||
occurrence := semanticCandidate["occurrences"].([]any)[0].(map[string]any)
|
||||
occurrence["name"] = ""
|
||||
occurrence["kind"] = "unsupported"
|
||||
ref := occurrence["source_refs"].([]any)[0].(map[string]any)
|
||||
ref["start_unit_id"] = 0
|
||||
ref["end_unit_id"] = -1
|
||||
@@ -38,12 +39,13 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||
t.Fatalf("schema rejected validator-owned semantics: %v", err)
|
||||
t.Fatalf("schema rejected non-categorical validator-owned semantics: %v", err)
|
||||
}
|
||||
|
||||
for _, mutate := range []func(map[string]any){
|
||||
func(record map[string]any) { delete(record, "name") },
|
||||
func(record map[string]any) { record["kind"] = 1 },
|
||||
func(record map[string]any) { record["kind"] = "unsupported" },
|
||||
func(record map[string]any) { record["npc_id"] = "npc:sha256:opaque" },
|
||||
func(record map[string]any) { record["unexpected"] = true },
|
||||
func(record map[string]any) {
|
||||
@@ -79,6 +81,24 @@ func TestResponseSchemaIsDefensiveAndContentSafe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaAcceptsEverySupportedKind(t *testing.T) {
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, kind := range []string{string(dnd.NPCOccurrenceKindMentioned), string(dnd.NPCOccurrenceKindNoncombatPresence), string(dnd.NPCOccurrenceKindDialogue), string(dnd.NPCOccurrenceKindCombatAlly), string(dnd.NPCOccurrenceKindCombatOpponent), string(dnd.NPCOccurrenceKindOther)} {
|
||||
candidate := validOccurrenceResponse()
|
||||
candidate["occurrences"].([]any)[0].(map[string]any)["kind"] = kind
|
||||
content, err := json.Marshal(candidate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateJSONSchema(content, schema.JSONSchema); err != nil {
|
||||
t.Fatalf("supported NPC-occurrence kind %q was rejected: %v", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validOccurrenceResponse() map[string]any {
|
||||
return map[string]any{"occurrences": []any{map[string]any{
|
||||
"name": "Mira Thorn", "kind": "dialogue",
|
||||
|
||||
@@ -75,18 +75,18 @@ func TestExtractReturnsSemanticallyInvalidResponseForDeterministicValidation(t *
|
||||
if err != nil {
|
||||
t.Fatalf("loadResponseSchema() error = %v", err)
|
||||
}
|
||||
if err := validateJSONSchema(t, map[string]any{"kind": "unrecognized", "title": " ", "summary": ""}, schema.JSONSchema); err != nil {
|
||||
if err := validateJSONSchema(t, map[string]any{"kind": "narrative", "title": " ", "summary": ""}, schema.JSONSchema); err != nil {
|
||||
t.Fatalf("semantic candidate rejected by private schema: %v", err)
|
||||
}
|
||||
client := &fakeSceneDescriptionsLLMClient{response: extractionResponse{
|
||||
Kind: dnd.SceneKind("unrecognized"), Title: " ", Summary: "",
|
||||
Kind: dnd.SceneKindNarrative, Title: " ", Summary: "",
|
||||
}}
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
scene := result.Value.Scenes[0]
|
||||
if scene.Kind != dnd.SceneKind("unrecognized") || scene.Title != "" || scene.Summary != "" {
|
||||
if scene.Kind != dnd.SceneKindNarrative || scene.Title != "" || scene.Summary != "" {
|
||||
t.Fatalf("scene = %#v, want semantic candidates returned for deterministic validation", scene)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func TestLoadResponseSchemaUsesStrictPrivateSceneDescriptionContract(t *testing.T) {
|
||||
@@ -30,7 +32,7 @@ func TestLoadResponseSchemaUsesStrictPrivateSceneDescriptionContract(t *testing.
|
||||
{name: "unknown framework field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "id": "assigned-later"}},
|
||||
{name: "unknown application field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "source_ref": map[string]any{}}},
|
||||
{name: "collection is not allowed", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "scenes": []any{}}},
|
||||
{name: "unsupported kind", response: map[string]any{"kind": "interlude", "title": "Ambush", "summary": "Bandits strike."}, valid: true},
|
||||
{name: "unsupported kind", response: map[string]any{"kind": "interlude", "title": "Ambush", "summary": "Bandits strike."}},
|
||||
{name: "empty title", response: map[string]any{"kind": "combat", "title": "", "summary": "Bandits strike."}, valid: true},
|
||||
{name: "empty summary", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": ""}, valid: true},
|
||||
{name: "wrong kind type", response: map[string]any{"kind": 7, "title": "Ambush", "summary": "Bandits strike."}},
|
||||
@@ -64,6 +66,19 @@ func TestResponseSchemaIsMutationSafeAndDiagnosticsRedactContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaAcceptsEverySupportedKind(t *testing.T) {
|
||||
schema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, kind := range []string{string(dnd.SceneKindCombat), string(dnd.SceneKindNarrative), string(dnd.SceneKindRecap), string(dnd.SceneKindMeta)} {
|
||||
response := map[string]any{"kind": kind, "title": "Title", "summary": "Summary"}
|
||||
if err := validateJSONSchema(t, response, schema.JSONSchema); err != nil {
|
||||
t.Fatalf("supported scene kind %q was rejected: %v", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateJSONSchema(t *testing.T, instance map[string]any, schemaContent []byte) error {
|
||||
t.Helper()
|
||||
content, err := json.Marshal(instance)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
frameworkdiagnostics "gitea.maximumdirect.net/eric/notarius/internal/framework/diagnostics"
|
||||
)
|
||||
@@ -25,6 +26,79 @@ type Finding struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
// Corrections collects domain-owned model instructions and contextual record
|
||||
// descriptions without coupling them to operator-facing diagnostics. Rules are
|
||||
// emitted before affected records so the bounded result remains useful even
|
||||
// when a large candidate exceeds the display budget.
|
||||
type Corrections struct {
|
||||
groups []correctionGroup
|
||||
indexes map[string]int
|
||||
}
|
||||
|
||||
type correctionGroup struct {
|
||||
rule string
|
||||
records []string
|
||||
recordsSeen map[string]struct{}
|
||||
}
|
||||
|
||||
// Add records one semantic rule and, when non-empty, one contextual record to
|
||||
// which it applies. key is local grouping state and is never rendered.
|
||||
func (c *Corrections) Add(key, rule, record string) {
|
||||
if c.indexes == nil {
|
||||
c.indexes = make(map[string]int)
|
||||
}
|
||||
index, ok := c.indexes[key]
|
||||
if !ok {
|
||||
index = len(c.groups)
|
||||
c.indexes[key] = index
|
||||
c.groups = append(c.groups, correctionGroup{rule: rule, recordsSeen: make(map[string]struct{})})
|
||||
}
|
||||
if record == "" {
|
||||
return
|
||||
}
|
||||
group := &c.groups[index]
|
||||
if _, seen := group.recordsSeen[record]; seen {
|
||||
return
|
||||
}
|
||||
group.recordsSeen[record] = struct{}{}
|
||||
group.records = append(group.records, record)
|
||||
}
|
||||
|
||||
// Guidance returns one bounded correction request. It deliberately renders
|
||||
// neither grouping keys nor operator diagnostics.
|
||||
func (c Corrections) Guidance(prefix string) string {
|
||||
issues := make([]string, 0, len(c.groups)*2)
|
||||
for _, group := range c.groups {
|
||||
issues = append(issues, group.rule)
|
||||
}
|
||||
for _, group := range c.groups {
|
||||
issues = append(issues, group.records...)
|
||||
}
|
||||
return Aggregate(prefix, issues)
|
||||
}
|
||||
|
||||
// SourceRange describes cited transcript positions without exposing source
|
||||
// identities or application entity IDs.
|
||||
func SourceRange(refs []source.SourceRef) string {
|
||||
if len(refs) == 0 {
|
||||
return "without a cited source range"
|
||||
}
|
||||
description := SourceRefRange(refs[0])
|
||||
if len(refs) > 1 {
|
||||
description += fmt.Sprintf(" (first of %d cited ranges)", len(refs))
|
||||
}
|
||||
return description
|
||||
}
|
||||
|
||||
// SourceRefRange describes one transcript range without exposing its source
|
||||
// identity.
|
||||
func SourceRefRange(ref source.SourceRef) string {
|
||||
if ref.StartUnitID == ref.EndUnitID {
|
||||
return "at source unit " + strconv.Itoa(ref.StartUnitID)
|
||||
}
|
||||
return fmt.Sprintf("at source units %d-%d", ref.StartUnitID, ref.EndUnitID)
|
||||
}
|
||||
|
||||
// DataQualityResult converts accepted source-quality findings into bounded,
|
||||
// locally grouped advisories. These findings do not indicate process
|
||||
// degradation.
|
||||
|
||||
@@ -6,9 +6,40 @@ import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestCorrectionsGroupRulesBeforeContextAndHideKeys(t *testing.T) {
|
||||
var corrections Corrections
|
||||
corrections.Add("internal-kind", "Use a supported kind.", "Affected goblin at source unit 4.")
|
||||
corrections.Add("internal-kind", "Use a supported kind.", "Affected ogre at source units 8-9.")
|
||||
corrections.Add("internal-kind", "Use a supported kind.", "Affected goblin at source unit 4.")
|
||||
corrections.Add("name", "Provide a contextual name.", "Affected unnamed record at source unit 12.")
|
||||
|
||||
guidance := corrections.Guidance("Correct every record")
|
||||
if strings.Contains(guidance, "internal-kind") {
|
||||
t.Fatalf("Guidance() exposed grouping key: %q", guidance)
|
||||
}
|
||||
if strings.Count(guidance, "Use a supported kind.") != 1 || strings.Count(guidance, "Affected goblin") != 1 {
|
||||
t.Fatalf("Guidance() did not de-duplicate rules and records: %q", guidance)
|
||||
}
|
||||
if strings.Index(guidance, "Use a supported kind.") > strings.Index(guidance, "Affected goblin") {
|
||||
t.Fatalf("Guidance() = %q, want rules before records", guidance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceRangeUsesOnlyTranscriptPositions(t *testing.T) {
|
||||
refs := []source.SourceRef{
|
||||
{SourceID: "opaque-source", StartUnitID: 8, EndUnitID: 10},
|
||||
{SourceID: "opaque-source", StartUnitID: 12, EndUnitID: 12},
|
||||
}
|
||||
got := SourceRange(refs)
|
||||
if strings.Contains(got, "opaque-source") || !strings.Contains(got, "8-10") || !strings.Contains(got, "first of 2") {
|
||||
t.Fatalf("SourceRange() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) {
|
||||
issues := make([]string, MaxIssues)
|
||||
for index := range issues {
|
||||
|
||||
48
internal/modules/dnd/shared/source_ref_coverage.go
Normal file
48
internal/modules/dnd/shared/source_ref_coverage.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package shared
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
|
||||
// ChunkCoverage is an immutable snapshot of the source units materialized in
|
||||
// one extraction chunk.
|
||||
type ChunkCoverage struct {
|
||||
sourceID string
|
||||
unitIDs map[int]struct{}
|
||||
}
|
||||
|
||||
// NewChunkCoverage snapshots chunk without retaining or mutating it.
|
||||
func NewChunkCoverage(chunk *source.Chunk) ChunkCoverage {
|
||||
if chunk == nil {
|
||||
return ChunkCoverage{}
|
||||
}
|
||||
coverage := ChunkCoverage{
|
||||
sourceID: chunk.SourceID,
|
||||
unitIDs: make(map[int]struct{}, len(chunk.Units)),
|
||||
}
|
||||
for _, unit := range chunk.Units {
|
||||
coverage.unitIDs[unit.ID] = struct{}{}
|
||||
}
|
||||
return coverage
|
||||
}
|
||||
|
||||
// Contains reports whether ref identifies a valid inclusive span in index and
|
||||
// every unit in that document-ordered span is present in the chunk snapshot.
|
||||
func (c ChunkCoverage) Contains(index source.DocumentIndex, doc *source.SourceDocument, ref source.SourceRef) bool {
|
||||
documentID, ok := index.DocumentID()
|
||||
if !ok || doc == nil || doc.ID != documentID || c.sourceID == "" || ref.SourceID != c.sourceID || ref.SourceID != documentID {
|
||||
return false
|
||||
}
|
||||
start, startOK := index.Position(ref.StartUnitID)
|
||||
end, endOK := index.Position(ref.EndUnitID)
|
||||
if !startOK || !endOK || start > end {
|
||||
return false
|
||||
}
|
||||
for position := start; position <= end; position++ {
|
||||
if position >= len(doc.Units) {
|
||||
return false
|
||||
}
|
||||
if _, found := c.unitIDs[doc.Units[position].ID]; !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
69
internal/modules/dnd/shared/source_ref_coverage_test.go
Normal file
69
internal/modules/dnd/shared/source_ref_coverage_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
func TestChunkCoverageRequiresCompleteDocumentOrderedSpan(t *testing.T) {
|
||||
doc := coverageDocument(30, 10, 20, 40)
|
||||
index := source.NewDocumentIndex(doc)
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
chunk *source.Chunk
|
||||
ref source.SourceRef
|
||||
want bool
|
||||
}{
|
||||
{name: "complete non-monotonic span", chunk: coverageChunk(doc.ID, 30, 10, 20), ref: coverageRef(doc.ID, 30, 20), want: true},
|
||||
{name: "single unit", chunk: coverageChunk(doc.ID, 10), ref: coverageRef(doc.ID, 10, 10), want: true},
|
||||
{name: "missing middle unit", chunk: coverageChunk(doc.ID, 30, 20), ref: coverageRef(doc.ID, 30, 20)},
|
||||
{name: "only endpoints", chunk: coverageChunk(doc.ID, 30, 40), ref: coverageRef(doc.ID, 30, 40)},
|
||||
{name: "reversed", chunk: coverageChunk(doc.ID, 30, 10), ref: coverageRef(doc.ID, 10, 30)},
|
||||
{name: "wrong source", chunk: coverageChunk(doc.ID, 30), ref: coverageRef("other", 30, 30)},
|
||||
{name: "missing endpoint", chunk: coverageChunk(doc.ID, 30), ref: coverageRef(doc.ID, 30, 999)},
|
||||
{name: "nil chunk", ref: coverageRef(doc.ID, 30, 30)},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
coverage := NewChunkCoverage(test.chunk)
|
||||
if got := coverage.Contains(index, doc, test.ref); got != test.want {
|
||||
t.Fatalf("Contains() = %t, want %t", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if NewChunkCoverage(coverageChunk(doc.ID, 30)).Contains(source.DocumentIndex{}, nil, coverageRef(doc.ID, 30, 30)) {
|
||||
t.Fatal("Contains() with nil document and zero index = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkCoverageDoesNotRetainMutableChunkState(t *testing.T) {
|
||||
doc := coverageDocument(1, 2)
|
||||
chunk := coverageChunk(doc.ID, 1, 2)
|
||||
coverage := NewChunkCoverage(chunk)
|
||||
chunk.SourceID = "changed"
|
||||
chunk.Units[0].ID = 99
|
||||
if !coverage.Contains(source.NewDocumentIndex(doc), doc, coverageRef(doc.ID, 1, 2)) {
|
||||
t.Fatal("Contains() changed after mutating source chunk")
|
||||
}
|
||||
}
|
||||
|
||||
func coverageDocument(ids ...int) *source.SourceDocument {
|
||||
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, len(ids))}
|
||||
for index, id := range ids {
|
||||
doc.Units[index] = source.SourceUnit{ID: id}
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
func coverageChunk(sourceID string, ids ...int) *source.Chunk {
|
||||
chunk := &source.Chunk{SourceID: sourceID, Units: make([]source.SourceUnit, len(ids))}
|
||||
for index, id := range ids {
|
||||
chunk.Units[index] = source.SourceUnit{ID: id}
|
||||
}
|
||||
return chunk
|
||||
}
|
||||
|
||||
func coverageRef(sourceID string, start, end int) source.SourceRef {
|
||||
return source.SourceRef{SourceID: sourceID, StartUnitID: start, EndUnitID: end}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/combat-turns/shape"
|
||||
ReasonCode = "invalid_combat_turn_shape"
|
||||
policy = "dnd.combat_turns.validator.shape.v1"
|
||||
policy = "dnd.combat_turns.validator.shape.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -33,38 +33,48 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) {
|
||||
if err := Validate(req.Value); err != nil {
|
||||
return rejection(err.Error()), nil
|
||||
issues, corrections := assess(req.Value)
|
||||
if len(issues) != 0 {
|
||||
return rejection(
|
||||
diagnostics.Aggregate("invalid combat turn shape", issues),
|
||||
corrections.Guidance("Correct every rejected combat turn and return the complete replacement combat-turn list"),
|
||||
), nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Validate(value dnd.CombatTurnList) error {
|
||||
issues := issuesFor(value)
|
||||
issues, _ := assess(value)
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid combat turn shape", issues))
|
||||
}
|
||||
|
||||
func issuesFor(value dnd.CombatTurnList) []string {
|
||||
func assess(value dnd.CombatTurnList) ([]string, diagnostics.Corrections) {
|
||||
var corrections diagnostics.Corrections
|
||||
if value.CombatTurns == nil {
|
||||
return []string{"combat_turns must be present"}
|
||||
corrections.Add("list", "Return a `combat_turns` array; use an empty array when the scene contains no combat turns.", "")
|
||||
return []string{"combat_turns must be present"}, corrections
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for turnIndex, turn := range value.CombatTurns {
|
||||
prefix := fmt.Sprintf("combat_turns[%d]", turnIndex)
|
||||
record := fmt.Sprintf("Affected %s for actor %s %s.", diagnostics.Quote(string(turn.TurnKind)), diagnostics.Quote(strings.TrimSpace(turn.Actor)), diagnostics.SourceRange(turn.SourceRefs))
|
||||
if strings.TrimSpace(turn.Actor) == "" {
|
||||
issues = append(issues, prefix+".actor must not be empty: "+diagnostics.Quote(turn.Actor))
|
||||
corrections.Add("actor", "Provide the contextual combatant name for every combat turn.", record)
|
||||
}
|
||||
if !validTurnKind(turn.TurnKind) {
|
||||
issues = append(issues, prefix+".turn_kind is unsupported: "+diagnostics.Quote(string(turn.TurnKind)))
|
||||
corrections.Add("kind", "Set `turn_kind` to exactly one of `turn`, `reaction`, `legendary_action`, `lair_action`, or `other`.", record)
|
||||
}
|
||||
if len(turn.SourceRefs) == 0 {
|
||||
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||
corrections.Add("source-refs", "Provide at least one transcript source range that directly supports every combat turn.", record)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
return issues, corrections
|
||||
}
|
||||
|
||||
func validTurnKind(value dnd.CombatTurnKind) bool {
|
||||
@@ -99,6 +109,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
||||
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
|
||||
func rejection(message string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete combat-turn list with every required field present, valid combatant names, and valid source references."}
|
||||
func rejection(message, guidance string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||
)
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/combat-turns/source_refs"
|
||||
ReasonCode = "invalid_combat_turn_source_refs"
|
||||
policy = "dnd.combat_turns.validator.source_refs.v2"
|
||||
policy = "dnd.combat_turns.validator.source_refs.v3"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -40,11 +41,11 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if err := combatshape.Validate(req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
var coverage *chunkCoverage
|
||||
var coverage shared.ChunkCoverage
|
||||
if req.Stage == string(pipeline.StageExtract) {
|
||||
coverage = newChunkCoverage(req.Chunk)
|
||||
coverage = shared.NewChunkCoverage(req.Chunk)
|
||||
}
|
||||
issues := sourceRefIssues(source.NewDocumentIndex(req.Source), req.Source, coverage, req.Value)
|
||||
issues, corrections := sourceRefIssues(source.NewDocumentIndex(req.Source), req.Source, coverage, req.Stage == string(pipeline.StageExtract), req.Value)
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
@@ -52,54 +53,28 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("invalid combat turn source references", issues),
|
||||
CorrectionGuidance: "Return combat turns whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each turn.",
|
||||
CorrectionGuidance: corrections.Guidance("Correct every rejected combat-turn citation and return the complete replacement combat-turn list"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sourceRefIssues(index source.DocumentIndex, doc *source.SourceDocument, coverage *chunkCoverage, value dnd.CombatTurnList) []string {
|
||||
func sourceRefIssues(index source.DocumentIndex, doc *source.SourceDocument, coverage shared.ChunkCoverage, checkCoverage bool, value dnd.CombatTurnList) ([]string, diagnostics.Corrections) {
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
for turnIndex, turn := range value.CombatTurns {
|
||||
for refIndex, ref := range turn.SourceRefs {
|
||||
record := fmt.Sprintf("Affected %s for actor %s, citing %s.", diagnostics.Quote(string(turn.TurnKind)), diagnostics.Quote(turn.Actor), diagnostics.SourceRefRange(ref))
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
issues = append(issues, fmt.Sprintf("combat_turns[%d].source_refs[%d]: %s", turnIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
corrections.Add("valid-range", "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first.", record)
|
||||
continue
|
||||
}
|
||||
if coverage != nil && !coverage.contains(doc, ref) {
|
||||
if checkCoverage && !coverage.Contains(index, doc, ref) {
|
||||
issues = append(issues, fmt.Sprintf("combat_turns[%d].source_refs[%d]: source reference is outside the current extraction chunk", turnIndex, refIndex))
|
||||
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
type chunkCoverage struct {
|
||||
sourceID string
|
||||
unitIDs map[int]struct{}
|
||||
}
|
||||
|
||||
func newChunkCoverage(chunk *source.Chunk) *chunkCoverage {
|
||||
coverage := &chunkCoverage{sourceID: chunk.SourceID, unitIDs: make(map[int]struct{}, len(chunk.Units))}
|
||||
for _, unit := range chunk.Units {
|
||||
coverage.unitIDs[unit.ID] = struct{}{}
|
||||
}
|
||||
return coverage
|
||||
}
|
||||
|
||||
func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool {
|
||||
if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID {
|
||||
return false
|
||||
}
|
||||
start, startOK := source.UnitIndex(doc, ref.StartUnitID)
|
||||
end, endOK := source.UnitIndex(doc, ref.EndUnitID)
|
||||
if !startOK || !endOK || start > end {
|
||||
return false
|
||||
}
|
||||
for position := start; position <= end; position++ {
|
||||
if _, found := coverage.unitIDs[doc.Units[position].ID]; !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return issues, corrections
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/enemy-events/engagements"
|
||||
ReasonCode = "duplicate_enemy_engagement"
|
||||
policy = "dnd.enemy_events.validator.engagements.v1"
|
||||
policy = "dnd.enemy_events.validator.engagements.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -38,7 +38,9 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if enemyeventshape.Validate(req.Value) != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
seen := make(map[string]dnd.EnemyEvent)
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
for _, event := range req.Value.Events {
|
||||
if event.Kind != dnd.EnemyEventKindEngaged {
|
||||
continue
|
||||
@@ -47,17 +49,24 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if identity == "" {
|
||||
continue
|
||||
}
|
||||
if _, found := seen[identity]; found {
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("duplicate enemy engagement", []string{
|
||||
fmt.Sprintf("subject %s has more than one engagement in one combat scene", diagnostics.Quote(event.Name)),
|
||||
}),
|
||||
CorrectionGuidance: "Return at most one engagement event for each contextual enemy name within the same combat scene.",
|
||||
}, nil
|
||||
if first, found := seen[identity]; found {
|
||||
issues = append(issues, fmt.Sprintf("subject %s has more than one engagement in one combat scene", diagnostics.Quote(event.Name)))
|
||||
corrections.Add(
|
||||
"duplicate-subject",
|
||||
"Return at most one `engaged` event for each contextual enemy name within this combat scene; keep the source ranges together on that one event.",
|
||||
fmt.Sprintf("Affected enemy %s: first engagement %s; additional engagement %s.", diagnostics.Quote(event.Name), diagnostics.SourceRange(first.SourceRefs), diagnostics.SourceRange(event.SourceRefs)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
seen[identity] = struct{}{}
|
||||
seen[identity] = event
|
||||
}
|
||||
if len(issues) != 0 {
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("duplicate enemy engagement", issues),
|
||||
CorrectionGuidance: corrections.Guidance("Correct every duplicate enemy engagement and return the complete replacement event list"),
|
||||
}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
@@ -66,6 +66,27 @@ func TestValidatorRegistersStrictOptionsAndPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorReportsEveryDuplicateSubjectWithContext(t *testing.T) {
|
||||
value := events(
|
||||
event("Ashfang", dnd.EnemyEventKindEngaged, 1),
|
||||
event("Ashfang", dnd.EnemyEventKindEngaged, 2),
|
||||
event("Briar", dnd.EnemyEventKindEngaged, 3),
|
||||
event("Briar", dnd.EnemyEventKindEngaged, 4),
|
||||
)
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: value})
|
||||
if err != nil || result.Approved {
|
||||
t.Fatalf("Validate() = %#v, %v; want rejection", result, err)
|
||||
}
|
||||
for _, want := range []string{"Ashfang", "Briar", "source unit 1", "source unit 2", "source unit 3", "source unit 4"} {
|
||||
if !strings.Contains(result.CorrectionGuidance, want) {
|
||||
t.Fatalf("CorrectionGuidance = %q, want %q", result.CorrectionGuidance, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(result.CorrectionGuidance, "events[") || strings.Contains(result.CorrectionGuidance, ReasonCode) {
|
||||
t.Fatalf("CorrectionGuidance exposed internal diagnostics: %q", result.CorrectionGuidance)
|
||||
}
|
||||
}
|
||||
|
||||
func events(values ...dnd.EnemyEvent) dnd.EnemyEventList { return dnd.EnemyEventList{Events: values} }
|
||||
|
||||
func event(name string, kind dnd.EnemyEventKind, unitID int) dnd.EnemyEvent {
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/enemy-events/shape"
|
||||
ReasonCode = "invalid_enemy_event_shape"
|
||||
policy = "dnd.enemy_events.validator.shape.v1"
|
||||
policy = "dnd.enemy_events.validator.shape.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -35,38 +35,50 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.EnemyEventList]) (contracts.ValidationResult, error) {
|
||||
if err := Validate(req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error(), CorrectionGuidance: "Return a complete enemy-event list with every required field present, valid contextual NPC names, supported event values, and valid source references."}, nil
|
||||
issues, corrections := assess(req.Value)
|
||||
if len(issues) != 0 {
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("invalid enemy event shape", issues),
|
||||
CorrectionGuidance: corrections.Guidance("Correct every rejected enemy event and return the complete replacement event list"),
|
||||
}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Validate(value dnd.EnemyEventList) error {
|
||||
issues := issuesFor(value)
|
||||
issues, _ := assess(value)
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid enemy event shape", issues))
|
||||
}
|
||||
|
||||
func issuesFor(value dnd.EnemyEventList) []string {
|
||||
func assess(value dnd.EnemyEventList) ([]string, diagnostics.Corrections) {
|
||||
var corrections diagnostics.Corrections
|
||||
if value.Events == nil {
|
||||
return []string{"events must be present"}
|
||||
corrections.Add("list", "Return an `events` array; use an empty array when the combat scene establishes no enemy events.", "")
|
||||
return []string{"events must be present"}, corrections
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for eventIndex, event := range value.Events {
|
||||
prefix := fmt.Sprintf("events[%d]", eventIndex)
|
||||
record := fmt.Sprintf("Affected %s event for enemy %s %s.", diagnostics.Quote(string(event.Kind)), diagnostics.Quote(strings.TrimSpace(event.Name)), diagnostics.SourceRange(event.SourceRefs))
|
||||
if strings.TrimSpace(event.Name) == "" {
|
||||
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name))
|
||||
corrections.Add("name", "Select a nonblank contextual enemy name from the supplied NPC registry for every event.", record)
|
||||
}
|
||||
if !enemyeventmodel.SupportedKind(event.Kind) {
|
||||
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(event.Kind)))
|
||||
corrections.Add("kind", "Set `kind` to exactly one of `engaged`, `killed`, `fled`, `captured`, or `incapacitated`.", record)
|
||||
}
|
||||
if len(event.SourceRefs) == 0 {
|
||||
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||
corrections.Add("source-refs", "Provide at least one transcript source range that directly supports every enemy event.", record)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
return issues, corrections
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
enemyeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/shape"
|
||||
)
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/enemy-events/source_refs"
|
||||
ReasonCode = "invalid_enemy_event_source_refs"
|
||||
policy = "dnd.enemy_events.validator.source_refs.v1"
|
||||
policy = "dnd.enemy_events.validator.source_refs.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -42,61 +43,40 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
var coverage *chunkCoverage
|
||||
var coverage shared.ChunkCoverage
|
||||
if req.Stage == string(pipeline.StageExtract) {
|
||||
coverage = newChunkCoverage(req.Chunk)
|
||||
coverage = shared.NewChunkCoverage(req.Chunk)
|
||||
}
|
||||
issues := sourceRefIssues(index, req.Source, coverage, req.Value)
|
||||
issues, corrections := sourceRefIssues(index, req.Source, coverage, req.Stage == string(pipeline.StageExtract), req.Value)
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid enemy event source references", issues), CorrectionGuidance: "Return enemy events whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each event."}, nil
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("invalid enemy event source references", issues),
|
||||
CorrectionGuidance: corrections.Guidance("Correct every rejected enemy-event citation and return the complete replacement event list"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sourceRefIssues(index source.DocumentIndex, doc *source.SourceDocument, coverage *chunkCoverage, value dnd.EnemyEventList) []string {
|
||||
func sourceRefIssues(index source.DocumentIndex, doc *source.SourceDocument, coverage shared.ChunkCoverage, checkCoverage bool, value dnd.EnemyEventList) ([]string, diagnostics.Corrections) {
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
for eventIndex, event := range value.Events {
|
||||
for refIndex, ref := range event.SourceRefs {
|
||||
record := fmt.Sprintf("Affected %s event for enemy %s, citing %s.", diagnostics.Quote(string(event.Kind)), diagnostics.Quote(event.Name), diagnostics.SourceRefRange(ref))
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
corrections.Add("valid-range", "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first.", record)
|
||||
continue
|
||||
}
|
||||
if coverage != nil && !coverage.contains(doc, ref) {
|
||||
if checkCoverage && !coverage.Contains(index, doc, ref) {
|
||||
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex))
|
||||
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
type chunkCoverage struct {
|
||||
sourceID string
|
||||
unitIDs map[int]struct{}
|
||||
}
|
||||
|
||||
func newChunkCoverage(chunk *source.Chunk) *chunkCoverage {
|
||||
coverage := &chunkCoverage{sourceID: chunk.SourceID, unitIDs: make(map[int]struct{}, len(chunk.Units))}
|
||||
for _, unit := range chunk.Units {
|
||||
coverage.unitIDs[unit.ID] = struct{}{}
|
||||
}
|
||||
return coverage
|
||||
}
|
||||
|
||||
func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool {
|
||||
if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID {
|
||||
return false
|
||||
}
|
||||
start, startOK := source.UnitIndex(doc, ref.StartUnitID)
|
||||
end, endOK := source.UnitIndex(doc, ref.EndUnitID)
|
||||
if !startOK || !endOK || start > end {
|
||||
return false
|
||||
}
|
||||
for position := start; position <= end; position++ {
|
||||
if _, found := coverage.unitIDs[doc.Units[position].ID]; !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return issues, corrections
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/item-occurrences/registry"
|
||||
ReasonCode = "invalid_item_occurrence_registry"
|
||||
policy = "dnd.item_occurrences.validator.registry.v1"
|
||||
policy = "dnd.item_occurrences.validator.registry.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -76,27 +76,33 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{}, fmt.Errorf("resolve item registry: %w", err)
|
||||
}
|
||||
if !registry.Bound() {
|
||||
return rejection([]string{"item registry reference is required"}), nil
|
||||
var corrections diagnostics.Corrections
|
||||
corrections.Add("registry", "Use only contextual item names from the supplied item registry; omit an occurrence that cannot be matched unambiguously.", "")
|
||||
return rejection([]string{"item registry reference is required"}, corrections), nil
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
for index, occurrence := range req.Value.Occurrences {
|
||||
record := fmt.Sprintf("Affected %s occurrence for item %s %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(occurrence.Name), diagnostics.SourceRange(occurrence.SourceRefs))
|
||||
item, ok := registry.LookupID(occurrence.ItemID)
|
||||
if !ok {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].item_id is not in the item registry: %s", index, diagnostics.Quote(occurrence.ItemID)))
|
||||
corrections.Add("registry", "Use only contextual item names from the supplied item registry; omit an occurrence that cannot be matched unambiguously.", record)
|
||||
continue
|
||||
}
|
||||
if occurrence.Name != item.Name {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d] does not match registry item %s", index, diagnostics.Quote(occurrence.ItemID)))
|
||||
corrections.Add("canonical-name", "Use the exact contextual item name supplied by the registry.", record+" Use registry name "+diagnostics.Quote(item.Name)+".")
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return rejection(issues), nil
|
||||
return rejection(issues, corrections), nil
|
||||
}
|
||||
|
||||
func rejection(issues []string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence registry", issues), CorrectionGuidance: "Return item occurrences using contextual item names that match an item in the supplied registry; omit occurrences that cannot be matched unambiguously."}
|
||||
func rejection(issues []string, corrections diagnostics.Corrections) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence registry", issues), CorrectionGuidance: corrections.Guidance("Correct every rejected item occurrence and return the complete replacement occurrence list")}
|
||||
}
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/item-occurrences/shape"
|
||||
ReasonCode = "invalid_item_occurrence_shape"
|
||||
policy = "dnd.item_occurrences.shape.v2"
|
||||
policy = "dnd.item_occurrences.shape.v3"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -57,20 +57,12 @@ func Validate(value dnd.ItemOccurrenceList) error {
|
||||
}
|
||||
|
||||
type validationAssessment struct {
|
||||
operatorIssues []string
|
||||
correctionGroups []correctionGroup
|
||||
groupIndexes map[string]int
|
||||
}
|
||||
|
||||
type correctionGroup struct {
|
||||
label string
|
||||
rule string
|
||||
records []string
|
||||
recordsSeen map[string]struct{}
|
||||
operatorIssues []string
|
||||
corrections diagnostics.Corrections
|
||||
}
|
||||
|
||||
func assess(value dnd.ItemOccurrenceList) validationAssessment {
|
||||
assessment := validationAssessment{groupIndexes: make(map[string]int)}
|
||||
var assessment validationAssessment
|
||||
if value.Occurrences == nil {
|
||||
assessment.operatorIssues = append(assessment.operatorIssues, "occurrences must be present")
|
||||
assessment.addCorrection("occurrences", "item-occurrence list", "Return an `occurrences` array; use an empty array when the transcript establishes no occurrences.", "")
|
||||
@@ -107,20 +99,10 @@ func assess(value dnd.ItemOccurrenceList) validationAssessment {
|
||||
}
|
||||
|
||||
func (assessment *validationAssessment) addCorrection(key, label, rule, record string) {
|
||||
index, ok := assessment.groupIndexes[key]
|
||||
if !ok {
|
||||
index = len(assessment.correctionGroups)
|
||||
assessment.groupIndexes[key] = index
|
||||
assessment.correctionGroups = append(assessment.correctionGroups, correctionGroup{label: label, rule: rule, recordsSeen: make(map[string]struct{})})
|
||||
}
|
||||
if record != "" {
|
||||
group := &assessment.correctionGroups[index]
|
||||
if _, seen := group.recordsSeen[record]; seen {
|
||||
return
|
||||
}
|
||||
group.recordsSeen[record] = struct{}{}
|
||||
group.records = append(group.records, record)
|
||||
record = "Affected " + label + " record: " + record
|
||||
}
|
||||
assessment.corrections.Add(key, rule, record)
|
||||
}
|
||||
|
||||
func (assessment validationAssessment) operatorMessage() string {
|
||||
@@ -128,16 +110,7 @@ func (assessment validationAssessment) operatorMessage() string {
|
||||
}
|
||||
|
||||
func (assessment validationAssessment) correctionGuidance() string {
|
||||
issues := make([]string, 0, len(assessment.correctionGroups)+len(assessment.operatorIssues))
|
||||
for _, group := range assessment.correctionGroups {
|
||||
issues = append(issues, group.rule)
|
||||
}
|
||||
for _, group := range assessment.correctionGroups {
|
||||
for _, record := range group.records {
|
||||
issues = append(issues, "Affected "+group.label+" record: "+record)
|
||||
}
|
||||
}
|
||||
return diagnostics.Aggregate("Correct every rejected item occurrence and return the complete replacement list", issues)
|
||||
return assessment.corrections.Guidance("Correct every rejected item occurrence and return the complete replacement list")
|
||||
}
|
||||
|
||||
func holderOperatorIssue(occurrence dnd.ItemOccurrence) string {
|
||||
@@ -159,7 +132,7 @@ func holderExpectation(kind dnd.ItemOccurrenceKind) string {
|
||||
case dnd.ItemOccurrenceKindConsumed:
|
||||
return "from present and to absent"
|
||||
case dnd.ItemOccurrenceKindTransferred:
|
||||
return "distinct named non-party holders"
|
||||
return "distinct named party-member holders"
|
||||
default:
|
||||
return "a supported holder combination"
|
||||
}
|
||||
@@ -188,19 +161,7 @@ func occurrenceContext(occurrence dnd.ItemOccurrence) string {
|
||||
if name == "" {
|
||||
context = "item with a blank contextual name"
|
||||
}
|
||||
if len(occurrence.SourceRefs) == 0 {
|
||||
context += " without a cited source range"
|
||||
} else {
|
||||
ref := occurrence.SourceRefs[0]
|
||||
if ref.StartUnitID == ref.EndUnitID {
|
||||
context += fmt.Sprintf(" at source unit %d", ref.StartUnitID)
|
||||
} else {
|
||||
context += fmt.Sprintf(" at source units %d-%d", ref.StartUnitID, ref.EndUnitID)
|
||||
}
|
||||
if len(occurrence.SourceRefs) > 1 {
|
||||
context += fmt.Sprintf(" (first of %d cited ranges)", len(occurrence.SourceRefs))
|
||||
}
|
||||
}
|
||||
context += " " + diagnostics.SourceRange(occurrence.SourceRefs)
|
||||
return context + " with from " + holderDisplay(occurrence.From) + " and to " + holderDisplay(occurrence.To)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
itemoccurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemoccurrences/shape"
|
||||
)
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/item-occurrences/source_refs"
|
||||
ReasonCode = "invalid_item_occurrence_source_references"
|
||||
policy = "dnd.item_occurrences.source_refs.v1"
|
||||
policy = "dnd.item_occurrences.source_refs.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -42,59 +43,35 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
var coverage *chunkCoverage
|
||||
var coverage shared.ChunkCoverage
|
||||
if req.Stage == string(pipeline.StageExtract) {
|
||||
coverage = newChunkCoverage(req.Chunk)
|
||||
coverage = shared.NewChunkCoverage(req.Chunk)
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
for occurrenceIndex, occurrence := range req.Value.Occurrences {
|
||||
for refIndex, ref := range occurrence.SourceRefs {
|
||||
record := fmt.Sprintf("Affected %s occurrence for item %s, citing %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(occurrence.Name), diagnostics.SourceRefRange(ref))
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
corrections.Add("valid-range", "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first.", record)
|
||||
continue
|
||||
}
|
||||
if coverage != nil && !coverage.contains(req.Source, ref) {
|
||||
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk", occurrenceIndex, refIndex))
|
||||
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence source references", issues), CorrectionGuidance: "Return item occurrences whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each occurrence."}, nil
|
||||
}
|
||||
|
||||
type chunkCoverage struct {
|
||||
sourceID string
|
||||
unitIDs map[int]struct{}
|
||||
}
|
||||
|
||||
func newChunkCoverage(chunk *source.Chunk) *chunkCoverage {
|
||||
coverage := &chunkCoverage{
|
||||
sourceID: chunk.SourceID,
|
||||
unitIDs: make(map[int]struct{}, len(chunk.Units)),
|
||||
}
|
||||
for _, unit := range chunk.Units {
|
||||
coverage.unitIDs[unit.ID] = struct{}{}
|
||||
}
|
||||
return coverage
|
||||
}
|
||||
|
||||
func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool {
|
||||
if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID {
|
||||
return false
|
||||
}
|
||||
start, startOK := source.UnitIndex(doc, ref.StartUnitID)
|
||||
end, endOK := source.UnitIndex(doc, ref.EndUnitID)
|
||||
if !startOK || !endOK || start > end {
|
||||
return false
|
||||
}
|
||||
for position := start; position <= end; position++ {
|
||||
if _, found := coverage.unitIDs[doc.Units[position].ID]; !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("invalid item occurrence source references", issues),
|
||||
CorrectionGuidance: corrections.Guidance("Correct every rejected item-occurrence citation and return the complete replacement occurrence list"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "normalize/dnd/item-registry/identity"
|
||||
ReasonCode = "invalid_item_identity"
|
||||
policy = domainidentity.Policy
|
||||
Key = "normalize/dnd/item-registry/identity"
|
||||
ReasonCode = "invalid_item_identity"
|
||||
policy = domainidentity.Policy
|
||||
correctionPolicy = "dnd.item_registry.validator.identity.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -31,7 +32,7 @@ func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "correction_policy", Value: correctionPolicy}}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemRegistry]) (contracts.ValidationResult, error) {
|
||||
@@ -43,10 +44,31 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
issues := make([]string, len(identityIssues))
|
||||
var corrections diagnostics.Corrections
|
||||
for index, issue := range identityIssues {
|
||||
issues[index] = fmt.Sprintf("items[%d] %s: %s", issue.RecordIndex, issue.Code, diagnostics.Quote(issue.Value))
|
||||
if issue.RecordIndex < 0 || issue.RecordIndex >= len(req.Value.Items) {
|
||||
continue
|
||||
}
|
||||
item := req.Value.Items[issue.RecordIndex]
|
||||
corrections.Add(string(issue.Code), itemIdentityCorrection(issue.Code), fmt.Sprintf("Affected item %s %s.", diagnostics.Quote(item.Name), diagnostics.SourceRange(item.SourceRefs)))
|
||||
}
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item identity", issues), CorrectionGuidance: corrections.Guidance("Correct the duplicate-group proposals and return the complete replacement proposal response")}, nil
|
||||
}
|
||||
|
||||
func itemIdentityCorrection(code domainidentity.IssueCode) string {
|
||||
switch code {
|
||||
case domainidentity.IssueEmptyCanonicalName:
|
||||
return "Select a canonical proposal member with a nonblank, transcript-supported item name."
|
||||
case domainidentity.IssueDuplicateCanonical:
|
||||
return "Put duplicate mentions of the same item in one proposal group and select one transcript-supported canonical member."
|
||||
case domainidentity.IssueInvalidID, domainidentity.IssueIDMismatch:
|
||||
return "Revise the proposal so its canonical item member has a valid transcript-supported name; Notarius derives durable identity without model input."
|
||||
case domainidentity.IssueDuplicateID:
|
||||
return "Do not use proposals that collapse distinct items into one canonical identity; group only records that describe the same item."
|
||||
default:
|
||||
return "Revise the duplicate-group proposal so every canonical item is transcript-supported and distinct."
|
||||
}
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item identity", issues), CorrectionGuidance: "Return one canonical registry entry per distinct properly named item, combining duplicate mentions under the same transcript-supported name."}, nil
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
|
||||
@@ -21,6 +21,9 @@ func TestValidatorRejectsInvalidAndDuplicateItemIdentity(t *testing.T) {
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "invalid_id") || !strings.Contains(result.Message, "duplicate_canonical_identity") {
|
||||
t.Fatalf("Validate() = %#v, %v; want identity rejection", result, err)
|
||||
}
|
||||
if !strings.Contains(result.CorrectionGuidance, "duplicate-group proposals") || !strings.Contains(result.CorrectionGuidance, "Rope") || strings.Contains(result.CorrectionGuidance, "wrong") || strings.Contains(result.CorrectionGuidance, "items[") {
|
||||
t.Fatalf("CorrectionGuidance = %q, want contextual proposal guidance without IDs or operator paths", result.CorrectionGuidance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRegistersPolicy(t *testing.T) {
|
||||
@@ -31,7 +34,7 @@ func TestValidatorRegistersPolicy(t *testing.T) {
|
||||
if got, ok := registry.Spec(Key); !ok || got != Spec() || got.ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("registered spec = %#v, present = %t", got, ok)
|
||||
}
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != domainidentity.Policy {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 2 || got[0].Value != domainidentity.Policy || got[1].Name != "correction_policy" || got[1].Value != correctionPolicy {
|
||||
t.Fatalf("fingerprints = %#v", got)
|
||||
}
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/item-registry/shape"
|
||||
ReasonCode = "invalid_item_shape"
|
||||
policy = "dnd.item_registry.validator.shape.v1"
|
||||
policy = "dnd.item_registry.validator.shape.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -34,39 +34,58 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemRegistry]) (contracts.ValidationResult, error) {
|
||||
issues := issuesFor(req.Value)
|
||||
normalization := req.Stage == string(pipeline.StageNormalize)
|
||||
issues, corrections := assess(req.Value, normalization)
|
||||
if len(issues) > 0 {
|
||||
return rejection(diagnostics.Aggregate("invalid item shape", issues)), nil
|
||||
prefix := "Correct every rejected item and return the complete replacement registry"
|
||||
if normalization {
|
||||
prefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
|
||||
}
|
||||
return rejection(diagnostics.Aggregate("invalid item shape", issues), corrections.Guidance(prefix)), nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Validate(value dnd.ItemRegistry) error {
|
||||
issues := issuesFor(value)
|
||||
issues, _ := assess(value, false)
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid item shape", issues))
|
||||
}
|
||||
|
||||
func issuesFor(value dnd.ItemRegistry) []string {
|
||||
func assess(value dnd.ItemRegistry, normalization bool) ([]string, diagnostics.Corrections) {
|
||||
var corrections diagnostics.Corrections
|
||||
nameRule := "Provide one nonblank, transcript-supported item name for every registry entry."
|
||||
refRule := "Provide at least one transcript source range that directly supports every named item."
|
||||
listRule := "Return an `items` array; use an empty array when the transcript establishes no named items."
|
||||
if normalization {
|
||||
nameRule = "Revise the duplicate-group proposals so every selected canonical item has a nonblank, transcript-supported name."
|
||||
refRule = "Revise the duplicate-group proposals so every selected canonical item preserves direct transcript evidence."
|
||||
listRule = "Revise the duplicate-group proposals so normalization retains the complete item candidate registry."
|
||||
}
|
||||
if value.Items == nil {
|
||||
return []string{"items must be present"}
|
||||
corrections.Add("list", listRule, "")
|
||||
return []string{"items must be present"}, corrections
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for index, item := range value.Items {
|
||||
prefix := fmt.Sprintf("items[%d]", index)
|
||||
record := "Affected item " + diagnostics.Quote(strings.TrimSpace(item.Name)) + " " + diagnostics.SourceRange(item.SourceRefs) + "."
|
||||
if strings.TrimSpace(item.ID) == "" {
|
||||
issues = append(issues, prefix+".id must not be empty")
|
||||
corrections.Add("name", nameRule, record)
|
||||
}
|
||||
if strings.TrimSpace(item.Name) == "" {
|
||||
issues = append(issues, prefix+".name must not be empty")
|
||||
corrections.Add("name", nameRule, record)
|
||||
}
|
||||
if len(item.SourceRefs) == 0 {
|
||||
issues = append(issues, prefix+".source_refs must not be empty")
|
||||
corrections.Add("source-refs", refRule, record)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
return issues, corrections
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
@@ -92,6 +111,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
||||
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
|
||||
func rejection(message string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete item registry containing only properly named items with valid source references."}
|
||||
func rejection(message, guidance string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
itemshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemregistry/shape"
|
||||
)
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/item-registry/source_refs"
|
||||
ReasonCode = "invalid_item_source_refs"
|
||||
policy = "dnd.item_registry.validator.source_refs.v1"
|
||||
policy = "dnd.item_registry.validator.source_refs.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -42,35 +43,37 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
coverage := shared.NewChunkCoverage(req.Chunk)
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
normalization := req.Stage == string(pipeline.StageNormalize)
|
||||
validRangeRule := "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first."
|
||||
guidancePrefix := "Correct every rejected item citation and return the complete replacement item registry"
|
||||
if normalization {
|
||||
validRangeRule = "Revise the duplicate-group proposals so every selected canonical item preserves valid transcript evidence; do not reproduce application IDs."
|
||||
guidancePrefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
|
||||
}
|
||||
for itemIndex, item := range req.Value.Items {
|
||||
for refIndex, ref := range item.SourceRefs {
|
||||
record := fmt.Sprintf("Affected item %s, citing %s.", diagnostics.Quote(item.Name), diagnostics.SourceRefRange(ref))
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
issues = append(issues, fmt.Sprintf("items[%d].source_refs[%d]: %s", itemIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
corrections.Add("valid-range", validRangeRule, record)
|
||||
continue
|
||||
}
|
||||
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
|
||||
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||
issues = append(issues, fmt.Sprintf("items[%d].source_refs[%d]: source reference is outside the current extraction chunk", itemIndex, refIndex))
|
||||
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return rejection(diagnostics.Aggregate("invalid item source references", issues)), nil
|
||||
}
|
||||
|
||||
func chunkContainsRef(chunk *source.Chunk, ref source.SourceRef) bool {
|
||||
if chunk == nil || ref.SourceID != chunk.SourceID {
|
||||
return false
|
||||
}
|
||||
startFound := false
|
||||
endFound := false
|
||||
for _, unit := range chunk.Units {
|
||||
startFound = startFound || unit.ID == ref.StartUnitID
|
||||
endFound = endFound || unit.ID == ref.EndUnitID
|
||||
}
|
||||
return startFound && endFound
|
||||
return rejection(
|
||||
diagnostics.Aggregate("invalid item source references", issues),
|
||||
corrections.Guidance(guidancePrefix),
|
||||
), nil
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
@@ -96,6 +99,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
||||
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
|
||||
func rejection(message string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return registry items whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each named item."}
|
||||
func rejection(message, guidance string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,17 @@ func TestValidatorDefersMalformedShapeAndRegisters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRequiresEveryUnitInCitedSpanToBeInChunk(t *testing.T) {
|
||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}, {ID: 20}}}
|
||||
value := validItemRegistry()
|
||||
value.Items[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 20}}
|
||||
chunk := &source.Chunk{SourceID: doc.ID, Units: []source.SourceUnit{{ID: 30}, {ID: 20}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemRegistry]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: value})
|
||||
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
|
||||
t.Fatalf("Validate() = %#v, %v; want missing-middle-unit rejection", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func request(doc *source.SourceDocument, value dnd.ItemRegistry) contracts.TypedValidationRequest[dnd.ItemRegistry] {
|
||||
return contracts.TypedValidationRequest[dnd.ItemRegistry]{Source: doc, Value: value}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/location-occurrences/registry"
|
||||
ReasonCode = "invalid_location_occurrence_registry"
|
||||
policy = "dnd.location_occurrences.validator.registry.v1"
|
||||
policy = "dnd.location_occurrences.validator.registry.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -76,27 +76,33 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{}, fmt.Errorf("resolve location registry: %w", err)
|
||||
}
|
||||
if !registry.Bound() {
|
||||
return rejection([]string{"location registry reference is required"}), nil
|
||||
var corrections diagnostics.Corrections
|
||||
corrections.Add("registry", "Use only proper contextual location names from the supplied location registry; omit an occurrence that cannot be matched unambiguously.", "")
|
||||
return rejection([]string{"location registry reference is required"}, corrections), nil
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
for index, occurrence := range req.Value.Occurrences {
|
||||
record := fmt.Sprintf("Affected %s occurrence for location %s %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(occurrence.Name), diagnostics.SourceRange(occurrence.SourceRefs))
|
||||
location, ok := registry.Lookup(occurrence.LocationID)
|
||||
if !ok {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].location_id is not in the location registry: %s", index, diagnostics.Quote(occurrence.LocationID)))
|
||||
corrections.Add("registry", "Use only proper contextual location names from the supplied location registry; omit an occurrence that cannot be matched unambiguously.", record)
|
||||
continue
|
||||
}
|
||||
if occurrence.Name != location.Name {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d] does not match registry location %s", index, diagnostics.Quote(occurrence.LocationID)))
|
||||
corrections.Add("canonical-name", "Use the exact proper location name supplied by the registry.", record+" Use registry name "+diagnostics.Quote(location.Name)+".")
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return rejection(issues), nil
|
||||
return rejection(issues, corrections), nil
|
||||
}
|
||||
|
||||
func rejection(issues []string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid location occurrence registry", issues), CorrectionGuidance: "Return location occurrences using proper contextual location names that match a location in the supplied registry; omit occurrences that cannot be matched unambiguously."}
|
||||
func rejection(issues []string, corrections diagnostics.Corrections) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid location occurrence registry", issues), CorrectionGuidance: corrections.Guidance("Correct every rejected location occurrence and return the complete replacement occurrence list")}
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/location-occurrences/shape"
|
||||
ReasonCode = "invalid_location_occurrence_shape"
|
||||
policy = "dnd.location_occurrences.validator.shape.v1"
|
||||
policy = "dnd.location_occurrences.validator.shape.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -34,41 +34,54 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationOccurrenceList]) (contracts.ValidationResult, error) {
|
||||
if err := Validate(req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error(), CorrectionGuidance: "Return a complete location-occurrence list with every required field present, valid contextual location names, supported occurrence kinds, and valid source references."}, nil
|
||||
issues, corrections := assess(req.Value)
|
||||
if len(issues) != 0 {
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("invalid location occurrence shape", issues),
|
||||
CorrectionGuidance: corrections.Guidance("Correct every rejected location occurrence and return the complete replacement occurrence list"),
|
||||
}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Validate(value dnd.LocationOccurrenceList) error {
|
||||
issues := issuesFor(value)
|
||||
issues, _ := assess(value)
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid location occurrence shape", issues))
|
||||
}
|
||||
|
||||
func issuesFor(value dnd.LocationOccurrenceList) []string {
|
||||
func assess(value dnd.LocationOccurrenceList) ([]string, diagnostics.Corrections) {
|
||||
var corrections diagnostics.Corrections
|
||||
if value.Occurrences == nil {
|
||||
return []string{"occurrences must be present"}
|
||||
corrections.Add("list", "Return an `occurrences` array; use an empty array when the transcript establishes no location occurrences.", "")
|
||||
return []string{"occurrences must be present"}, corrections
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for index, occurrence := range value.Occurrences {
|
||||
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||
record := fmt.Sprintf("Affected %s occurrence for location %s %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(strings.TrimSpace(occurrence.Name)), diagnostics.SourceRange(occurrence.SourceRefs))
|
||||
if strings.TrimSpace(occurrence.LocationID) == "" {
|
||||
issues = append(issues, prefix+".location_id must not be empty")
|
||||
corrections.Add("name", "Select a nonblank proper location name from the supplied registry for every occurrence.", record)
|
||||
}
|
||||
if strings.TrimSpace(occurrence.Name) == "" {
|
||||
issues = append(issues, prefix+".name must not be empty")
|
||||
corrections.Add("name", "Select a nonblank proper location name from the supplied registry for every occurrence.", record)
|
||||
}
|
||||
if !validKind(occurrence.Kind) {
|
||||
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(occurrence.Kind)))
|
||||
corrections.Add("kind", "Set `kind` to exactly one of `visited`, `planned`, `recalled`, or `mentioned`.", record)
|
||||
}
|
||||
if len(occurrence.SourceRefs) == 0 {
|
||||
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||
corrections.Add("source-refs", "Provide at least one transcript source range that directly supports every location occurrence.", record)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
return issues, corrections
|
||||
}
|
||||
|
||||
func validKind(value dnd.LocationOccurrenceKind) bool {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationoccurrences/shape"
|
||||
)
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/location-occurrences/source_refs"
|
||||
ReasonCode = "invalid_location_occurrence_source_refs"
|
||||
policy = "dnd.location_occurrences.validator.source_refs.v1"
|
||||
policy = "dnd.location_occurrences.validator.source_refs.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -42,35 +43,34 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
coverage := shared.NewChunkCoverage(req.Chunk)
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
for occurrenceIndex, occurrence := range req.Value.Occurrences {
|
||||
for refIndex, ref := range occurrence.SourceRefs {
|
||||
record := fmt.Sprintf("Affected %s occurrence for location %s, citing %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(occurrence.Name), diagnostics.SourceRefRange(ref))
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
corrections.Add("valid-range", "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first.", record)
|
||||
continue
|
||||
}
|
||||
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
|
||||
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk", occurrenceIndex, refIndex))
|
||||
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid location occurrence source references", issues), CorrectionGuidance: "Return location occurrences whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each occurrence."}, nil
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("invalid location occurrence source references", issues),
|
||||
CorrectionGuidance: corrections.Guidance("Correct every rejected location-occurrence citation and return the complete replacement occurrence list"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func chunkContainsRef(chunk *source.Chunk, ref source.SourceRef) bool {
|
||||
if chunk == nil || ref.SourceID != chunk.SourceID {
|
||||
return false
|
||||
}
|
||||
startFound, endFound := false, false
|
||||
for _, unit := range chunk.Units {
|
||||
startFound = startFound || unit.ID == ref.StartUnitID
|
||||
endFound = endFound || unit.ID == ref.EndUnitID
|
||||
}
|
||||
return startFound && endFound
|
||||
}
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "normalize/dnd/location-registry/identity"
|
||||
ReasonCode = "invalid_location_identity"
|
||||
policy = domainidentity.Policy
|
||||
Key = "normalize/dnd/location-registry/identity"
|
||||
ReasonCode = "invalid_location_identity"
|
||||
policy = domainidentity.Policy
|
||||
correctionPolicy = "dnd.location_registry.validator.identity.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -31,7 +32,7 @@ func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "correction_policy", Value: correctionPolicy}}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationRegistry]) (contracts.ValidationResult, error) {
|
||||
@@ -43,17 +44,38 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
issues := make([]string, len(identityIssues))
|
||||
var corrections diagnostics.Corrections
|
||||
for index, issue := range identityIssues {
|
||||
issues[index] = fmt.Sprintf("locations[%d] %s: %s", issue.RecordIndex, issue.Code, diagnostics.Quote(issue.Value))
|
||||
if issue.RecordIndex < 0 || issue.RecordIndex >= len(req.Value.Locations) {
|
||||
continue
|
||||
}
|
||||
location := req.Value.Locations[issue.RecordIndex]
|
||||
corrections.Add(string(issue.Code), locationIdentityCorrection(issue.Code), fmt.Sprintf("Affected location %s %s.", diagnostics.Quote(location.Name), diagnostics.SourceRange(location.SourceRefs)))
|
||||
}
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("invalid location identity", issues),
|
||||
CorrectionGuidance: "Return one canonical registry entry per distinct proper location name, combining duplicate mentions of the same location.",
|
||||
CorrectionGuidance: corrections.Guidance("Correct the duplicate-group proposals and return the complete replacement proposal response"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func locationIdentityCorrection(code domainidentity.IssueCode) string {
|
||||
switch code {
|
||||
case domainidentity.IssueEmptyCanonicalName:
|
||||
return "Select a canonical proposal member with a nonblank, transcript-supported proper location name."
|
||||
case domainidentity.IssueMissingEvidence:
|
||||
return "Select a canonical proposal member that has direct transcript evidence; do not discard all supported evidence for a location."
|
||||
case domainidentity.IssueInvalidID, domainidentity.IssueIDMismatch:
|
||||
return "Revise the proposal so its canonical location member has a valid name and evidence anchor; Notarius derives durable identity without model input."
|
||||
case domainidentity.IssueDuplicateID:
|
||||
return "Do not use proposals that collapse distinct locations or evidence anchors into one canonical identity; group only records that describe the same location."
|
||||
default:
|
||||
return "Revise the duplicate-group proposal so every canonical location is transcript-supported and distinct."
|
||||
}
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
@@ -47,10 +47,13 @@ func TestValidatorDefersShapeAndRejectsDerivationAndDuplicateID(t *testing.T) {
|
||||
t.Fatalf("message %q missing %q", result.Message, want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(result.CorrectionGuidance, "duplicate-group proposals") || !strings.Contains(result.CorrectionGuidance, "Gate") || strings.Contains(result.CorrectionGuidance, "not-an-id") || strings.Contains(result.CorrectionGuidance, "locations[") {
|
||||
t.Fatalf("CorrectionGuidance = %q, want contextual proposal guidance without IDs or operator paths", result.CorrectionGuidance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRegistersIdentityPolicy(t *testing.T) {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Value != domainidentity.Policy {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 2 || got[0].Value != domainidentity.Policy || got[1].Name != "correction_policy" || got[1].Value != correctionPolicy {
|
||||
t.Fatalf("fingerprints = %#v", got)
|
||||
}
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/location-registry/shape"
|
||||
ReasonCode = "invalid_location_shape"
|
||||
policy = "dnd.location_registry.validator.shape.v1"
|
||||
policy = "dnd.location_registry.validator.shape.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -34,39 +34,58 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationRegistry]) (contracts.ValidationResult, error) {
|
||||
issues := issuesFor(req.Value)
|
||||
normalization := req.Stage == string(pipeline.StageNormalize)
|
||||
issues, corrections := assess(req.Value, normalization)
|
||||
if len(issues) > 0 {
|
||||
return rejection(diagnostics.Aggregate("invalid location shape", issues)), nil
|
||||
prefix := "Correct every rejected location and return the complete replacement registry"
|
||||
if normalization {
|
||||
prefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
|
||||
}
|
||||
return rejection(diagnostics.Aggregate("invalid location shape", issues), corrections.Guidance(prefix)), nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Validate(value dnd.LocationRegistry) error {
|
||||
issues := issuesFor(value)
|
||||
issues, _ := assess(value, false)
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid location shape", issues))
|
||||
}
|
||||
|
||||
func issuesFor(value dnd.LocationRegistry) []string {
|
||||
func assess(value dnd.LocationRegistry, normalization bool) ([]string, diagnostics.Corrections) {
|
||||
var corrections diagnostics.Corrections
|
||||
nameRule := "Provide one nonblank, transcript-supported proper location name for every registry entry."
|
||||
refRule := "Provide at least one transcript source range that directly supports every named location."
|
||||
listRule := "Return a `locations` array; use an empty array when the transcript establishes no named locations."
|
||||
if normalization {
|
||||
nameRule = "Revise the duplicate-group proposals so every selected canonical location has a nonblank, transcript-supported proper name."
|
||||
refRule = "Revise the duplicate-group proposals so every selected canonical location preserves direct transcript evidence."
|
||||
listRule = "Revise the duplicate-group proposals so normalization retains the complete location candidate registry."
|
||||
}
|
||||
if value.Locations == nil {
|
||||
return []string{"locations must be present"}
|
||||
corrections.Add("list", listRule, "")
|
||||
return []string{"locations must be present"}, corrections
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for index, location := range value.Locations {
|
||||
prefix := fmt.Sprintf("locations[%d]", index)
|
||||
record := "Affected location " + diagnostics.Quote(strings.TrimSpace(location.Name)) + " " + diagnostics.SourceRange(location.SourceRefs) + "."
|
||||
if strings.TrimSpace(location.ID) == "" {
|
||||
issues = append(issues, prefix+".id must not be empty")
|
||||
corrections.Add("name", nameRule, record)
|
||||
}
|
||||
if strings.TrimSpace(location.Name) == "" {
|
||||
issues = append(issues, prefix+".name must not be empty")
|
||||
corrections.Add("name", nameRule, record)
|
||||
}
|
||||
if len(location.SourceRefs) == 0 {
|
||||
issues = append(issues, prefix+".source_refs must not be empty")
|
||||
corrections.Add("source-refs", refRule, record)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
return issues, corrections
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
@@ -92,6 +111,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
||||
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
|
||||
func rejection(message string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete location registry containing only properly named locations with valid source references."}
|
||||
func rejection(message, guidance string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
locationshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/locationregistry/shape"
|
||||
)
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/location-registry/source_refs"
|
||||
ReasonCode = "invalid_location_source_refs"
|
||||
policy = "dnd.location_registry.validator.source_refs.v2"
|
||||
policy = "dnd.location_registry.validator.source_refs.v3"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -42,35 +43,37 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
coverage := shared.NewChunkCoverage(req.Chunk)
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
normalization := req.Stage == string(pipeline.StageNormalize)
|
||||
validRangeRule := "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first."
|
||||
guidancePrefix := "Correct every rejected location citation and return the complete replacement location registry"
|
||||
if normalization {
|
||||
validRangeRule = "Revise the duplicate-group proposals so every selected canonical location preserves valid transcript evidence; do not reproduce application IDs."
|
||||
guidancePrefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
|
||||
}
|
||||
for locationIndex, location := range req.Value.Locations {
|
||||
for refIndex, ref := range location.SourceRefs {
|
||||
record := fmt.Sprintf("Affected location %s, citing %s.", diagnostics.Quote(location.Name), diagnostics.SourceRefRange(ref))
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
issues = append(issues, fmt.Sprintf("locations[%d].source_refs[%d]: %s", locationIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
corrections.Add("valid-range", validRangeRule, record)
|
||||
continue
|
||||
}
|
||||
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
|
||||
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||
issues = append(issues, fmt.Sprintf("locations[%d].source_refs[%d]: source reference is outside the current extraction chunk", locationIndex, refIndex))
|
||||
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return rejection(diagnostics.Aggregate("invalid location source references", issues)), nil
|
||||
}
|
||||
|
||||
func chunkContainsRef(chunk *source.Chunk, ref source.SourceRef) bool {
|
||||
if chunk == nil || ref.SourceID != chunk.SourceID {
|
||||
return false
|
||||
}
|
||||
startFound := false
|
||||
endFound := false
|
||||
for _, unit := range chunk.Units {
|
||||
startFound = startFound || unit.ID == ref.StartUnitID
|
||||
endFound = endFound || unit.ID == ref.EndUnitID
|
||||
}
|
||||
return startFound && endFound
|
||||
return rejection(
|
||||
diagnostics.Aggregate("invalid location source references", issues),
|
||||
corrections.Guidance(guidancePrefix),
|
||||
), nil
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
@@ -96,6 +99,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
||||
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
|
||||
func rejection(message string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return registry locations whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each proper location name."}
|
||||
func rejection(message, guidance string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/npc-occurrences/registry"
|
||||
ReasonCode = "invalid_npc_occurrence_registry"
|
||||
policy = "dnd.npc_occurrences.validator.registry.v1"
|
||||
policy = "dnd.npc_occurrences.validator.registry.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -84,31 +84,37 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{}, fmt.Errorf("resolve NPC registry: %w", err)
|
||||
}
|
||||
if !npcRegistry.Bound() {
|
||||
return rejection([]string{"NPC registry reference is required"}), nil
|
||||
var corrections diagnostics.Corrections
|
||||
corrections.Add("registry", "Use only contextual NPC names from the supplied NPC registry; omit an occurrence that cannot be matched unambiguously.", "")
|
||||
return rejection([]string{"NPC registry reference is required"}, corrections), nil
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
for index, occurrence := range req.Value.Occurrences {
|
||||
record := fmt.Sprintf("Affected %s occurrence for NPC %s %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(occurrence.Name), diagnostics.SourceRange(occurrence.SourceRefs))
|
||||
canonical, ok := npcRegistry.LookupID(occurrence.NPCID)
|
||||
if !ok {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].npc_id is not in the NPC registry: %s", index, diagnostics.Quote(occurrence.NPCID)))
|
||||
corrections.Add("registry", "Use only contextual NPC names from the supplied NPC registry; omit an occurrence that cannot be matched unambiguously.", record)
|
||||
continue
|
||||
}
|
||||
if occurrence.Name != canonical.Name {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].name does not match npc_id: %s", index, diagnostics.Quote(occurrence.Name)))
|
||||
corrections.Add("canonical-name", "Use the exact contextual NPC name supplied by the registry.", record+" Use registry name "+diagnostics.Quote(canonical.Name)+".")
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return rejection(issues), nil
|
||||
return rejection(issues, corrections), nil
|
||||
}
|
||||
|
||||
func rejection(issues []string) contracts.ValidationResult {
|
||||
func rejection(issues []string, corrections diagnostics.Corrections) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("invalid NPC occurrence registry", issues),
|
||||
CorrectionGuidance: "Return NPC occurrences using contextual NPC names that match an NPC in the supplied registry; omit occurrences that cannot be matched unambiguously.",
|
||||
CorrectionGuidance: corrections.Guidance("Correct every rejected NPC occurrence and return the complete replacement occurrence list"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/npc-occurrences/shape"
|
||||
ReasonCode = "invalid_npc_occurrence_shape"
|
||||
policy = "dnd.npc_occurrences.validator.shape.v1"
|
||||
policy = "dnd.npc_occurrences.validator.shape.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -34,41 +34,54 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
|
||||
if err := Validate(req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error(), CorrectionGuidance: "Return a complete NPC-occurrence list with every required field present, valid contextual NPC names, supported interaction kinds, and valid source references."}, nil
|
||||
issues, corrections := assess(req.Value)
|
||||
if len(issues) != 0 {
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("invalid NPC occurrence shape", issues),
|
||||
CorrectionGuidance: corrections.Guidance("Correct every rejected NPC occurrence and return the complete replacement occurrence list"),
|
||||
}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Validate(value dnd.NPCOccurrenceList) error {
|
||||
issues := issuesFor(value)
|
||||
issues, _ := assess(value)
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC occurrence shape", issues))
|
||||
}
|
||||
|
||||
func issuesFor(value dnd.NPCOccurrenceList) []string {
|
||||
func assess(value dnd.NPCOccurrenceList) ([]string, diagnostics.Corrections) {
|
||||
var corrections diagnostics.Corrections
|
||||
if value.Occurrences == nil {
|
||||
return []string{"occurrences must be present"}
|
||||
corrections.Add("list", "Return an `occurrences` array; use an empty array when the transcript establishes no NPC occurrences.", "")
|
||||
return []string{"occurrences must be present"}, corrections
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for index, occurrence := range value.Occurrences {
|
||||
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||
record := fmt.Sprintf("Affected %s occurrence for NPC %s %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(strings.TrimSpace(occurrence.Name)), diagnostics.SourceRange(occurrence.SourceRefs))
|
||||
if strings.TrimSpace(occurrence.NPCID) == "" {
|
||||
issues = append(issues, prefix+".npc_id must not be empty: "+diagnostics.Quote(occurrence.NPCID))
|
||||
corrections.Add("name", "Select a nonblank contextual NPC name from the supplied registry for every occurrence.", record)
|
||||
}
|
||||
if strings.TrimSpace(occurrence.Name) == "" {
|
||||
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(occurrence.Name))
|
||||
corrections.Add("name", "Select a nonblank contextual NPC name from the supplied registry for every occurrence.", record)
|
||||
}
|
||||
if !validKind(occurrence.Kind) {
|
||||
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(occurrence.Kind)))
|
||||
corrections.Add("kind", "Set `kind` to exactly one of `mentioned`, `noncombat_presence`, `dialogue`, `combat_ally`, `combat_opponent`, or `other`.", record)
|
||||
}
|
||||
if len(occurrence.SourceRefs) == 0 {
|
||||
issues = append(issues, prefix+".source_refs must contain at least one reference")
|
||||
corrections.Add("source-refs", "Provide at least one transcript source range that directly supports every NPC occurrence.", record)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
return issues, corrections
|
||||
}
|
||||
|
||||
func validKind(value dnd.NPCOccurrenceKind) bool {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcoccurrences/shape"
|
||||
)
|
||||
@@ -16,7 +17,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/npc-occurrences/source_refs"
|
||||
ReasonCode = "invalid_npc_occurrence_source_refs"
|
||||
policy = "dnd.npc_occurrences.validator.source_refs.v2"
|
||||
policy = "dnd.npc_occurrences.validator.source_refs.v3"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -42,18 +43,23 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
coverage := shared.NewChunkCoverage(req.Chunk)
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
for occurrenceIndex, occurrence := range req.Value.Occurrences {
|
||||
for refIndex, ref := range occurrence.SourceRefs {
|
||||
record := fmt.Sprintf("Affected %s occurrence for NPC %s, citing %s.", diagnostics.Quote(string(occurrence.Kind)), diagnostics.Quote(occurrence.Name), diagnostics.SourceRefRange(ref))
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
corrections.Add("valid-range", "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first.", record)
|
||||
continue
|
||||
}
|
||||
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
|
||||
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||
issues = append(issues, fmt.Sprintf(
|
||||
"occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk",
|
||||
occurrenceIndex, refIndex,
|
||||
))
|
||||
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,23 +70,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("invalid NPC occurrence source references", issues),
|
||||
CorrectionGuidance: "Return NPC occurrences whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each occurrence.",
|
||||
CorrectionGuidance: corrections.Guidance("Correct every rejected NPC-occurrence citation and return the complete replacement occurrence list"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func chunkContainsRef(chunk *source.Chunk, ref source.SourceRef) bool {
|
||||
if chunk == nil || ref.SourceID != chunk.SourceID {
|
||||
return false
|
||||
}
|
||||
startFound := false
|
||||
endFound := false
|
||||
for _, unit := range chunk.Units {
|
||||
startFound = startFound || unit.ID == ref.StartUnitID
|
||||
endFound = endFound || unit.ID == ref.EndUnitID
|
||||
}
|
||||
return startFound && endFound
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "normalize/dnd/npc-registry/identity"
|
||||
ReasonCode = "invalid_npc_identity"
|
||||
policy = domainidentity.Policy
|
||||
Key = "normalize/dnd/npc-registry/identity"
|
||||
ReasonCode = "invalid_npc_identity"
|
||||
policy = domainidentity.Policy
|
||||
correctionPolicy = "dnd.npc_registry.validator.identity.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -33,7 +34,7 @@ func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||
}
|
||||
|
||||
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "correction_policy", Value: correctionPolicy}}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCRegistry]) (contracts.ValidationResult, error) {
|
||||
@@ -46,18 +47,39 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
}
|
||||
|
||||
issues := make([]string, len(identityIssues))
|
||||
var corrections diagnostics.Corrections
|
||||
for index, issue := range identityIssues {
|
||||
location := fmt.Sprintf("npcs[%d]", issue.RecordIndex)
|
||||
issues[index] = fmt.Sprintf("%s %s: %s", location, issue.Code, diagnostics.Quote(issue.Value))
|
||||
if issue.RecordIndex < 0 || issue.RecordIndex >= len(req.Value.NPCs) {
|
||||
continue
|
||||
}
|
||||
npc := req.Value.NPCs[issue.RecordIndex]
|
||||
corrections.Add(string(issue.Code), npcIdentityCorrection(issue.Code), fmt.Sprintf("Affected NPC %s %s.", diagnostics.Quote(npc.Name), diagnostics.SourceRange(npc.SourceRefs)))
|
||||
}
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("invalid NPC identity", issues),
|
||||
CorrectionGuidance: "Return one canonical registry entry per distinct properly named NPC, combining duplicate mentions under the same transcript-supported name.",
|
||||
CorrectionGuidance: corrections.Guidance("Correct the duplicate-group proposals and return the complete replacement proposal response"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func npcIdentityCorrection(code domainidentity.IssueCode) string {
|
||||
switch code {
|
||||
case domainidentity.IssueEmptyCanonicalName:
|
||||
return "Select a canonical proposal member with a nonblank, transcript-supported proper NPC name."
|
||||
case domainidentity.IssueDuplicateCanonical:
|
||||
return "Put duplicate mentions of the same NPC in one proposal group and select one transcript-supported canonical member."
|
||||
case domainidentity.IssueInvalidID, domainidentity.IssueIDMismatch:
|
||||
return "Revise the proposal so its canonical NPC member has a valid transcript-supported name; Notarius derives durable identity without model input."
|
||||
case domainidentity.IssueDuplicateID:
|
||||
return "Do not use proposals that collapse distinct NPCs into one canonical identity; group only records that describe the same NPC."
|
||||
default:
|
||||
return "Revise the duplicate-group proposal so every canonical NPC is transcript-supported and distinct."
|
||||
}
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestValidatorContractAndRegistration(t *testing.T) {
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
||||
t.Fatalf("DecodeOptions() error = %v, want unknown option error", err)
|
||||
}
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 2 || got[0].Name != "policy" || got[0].Value != policy || got[1].Name != "correction_policy" || got[1].Value != correctionPolicy {
|
||||
t.Fatalf("fingerprints = %#v, want identity policy", got)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ func TestValidatorDefersShapeAndRejectsIdentityIssues(t *testing.T) {
|
||||
t.Fatalf("identity message %q missing %q", result.Message, want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(result.CorrectionGuidance, "duplicate-group proposals") || !strings.Contains(result.CorrectionGuidance, value.NPCs[0].Name) || strings.Contains(result.CorrectionGuidance, value.NPCs[0].ID) || strings.Contains(result.CorrectionGuidance, "npcs[") {
|
||||
t.Fatalf("CorrectionGuidance = %q, want contextual proposal guidance without IDs or operator paths", result.CorrectionGuidance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorBoundsUnicodeDiagnostics(t *testing.T) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/npc-registry/shape"
|
||||
ReasonCode = "invalid_npc_shape"
|
||||
policy = "dnd.npc_registry.validator.shape.v1"
|
||||
policy = "dnd.npc_registry.validator.shape.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -33,39 +33,58 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCRegistry]) (contracts.ValidationResult, error) {
|
||||
issues := issuesFor(req.Value)
|
||||
normalization := req.Stage == string(pipeline.StageNormalize)
|
||||
issues, corrections := assess(req.Value, normalization)
|
||||
if len(issues) > 0 {
|
||||
return rejection(diagnostics.Aggregate("invalid NPC shape", issues)), nil
|
||||
prefix := "Correct every rejected NPC and return the complete replacement registry"
|
||||
if normalization {
|
||||
prefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
|
||||
}
|
||||
return rejection(diagnostics.Aggregate("invalid NPC shape", issues), corrections.Guidance(prefix)), nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Validate(value dnd.NPCRegistry) error {
|
||||
issues := issuesFor(value)
|
||||
issues, _ := assess(value, false)
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC shape", issues))
|
||||
}
|
||||
|
||||
func issuesFor(value dnd.NPCRegistry) []string {
|
||||
func assess(value dnd.NPCRegistry, normalization bool) ([]string, diagnostics.Corrections) {
|
||||
var corrections diagnostics.Corrections
|
||||
nameRule := "Provide one nonblank, transcript-supported proper NPC name for every registry entry."
|
||||
refRule := "Provide at least one transcript source range that directly supports every named NPC."
|
||||
listRule := "Return an `npcs` array; use an empty array when the transcript establishes no named NPCs."
|
||||
if normalization {
|
||||
nameRule = "Revise the duplicate-group proposals so every selected canonical NPC has a nonblank, transcript-supported proper name."
|
||||
refRule = "Revise the duplicate-group proposals so every selected canonical NPC preserves direct transcript evidence."
|
||||
listRule = "Revise the duplicate-group proposals so normalization retains the complete NPC candidate registry."
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
if value.NPCs == nil {
|
||||
return []string{"npcs must be present"}
|
||||
corrections.Add("list", listRule, "")
|
||||
return []string{"npcs must be present"}, corrections
|
||||
}
|
||||
for index, npc := range value.NPCs {
|
||||
prefix := fmt.Sprintf("npcs[%d]", index)
|
||||
record := "Affected NPC " + diagnostics.Quote(strings.TrimSpace(npc.Name)) + " " + diagnostics.SourceRange(npc.SourceRefs) + "."
|
||||
if strings.TrimSpace(npc.ID) == "" {
|
||||
issues = append(issues, prefix+".id must not be empty")
|
||||
corrections.Add("name", nameRule, record)
|
||||
}
|
||||
if strings.TrimSpace(npc.Name) == "" {
|
||||
issues = append(issues, prefix+".name must not be empty")
|
||||
corrections.Add("name", nameRule, record)
|
||||
}
|
||||
if len(npc.SourceRefs) == 0 {
|
||||
issues = append(issues, prefix+".source_refs must not be empty")
|
||||
corrections.Add("source-refs", refRule, record)
|
||||
}
|
||||
}
|
||||
return issues
|
||||
return issues, corrections
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
@@ -91,6 +110,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
||||
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
|
||||
func rejection(message string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete NPC registry containing only properly named NPCs with valid source references."}
|
||||
func rejection(message, guidance string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestValidatorBoundsDiagnosticsAndQuotesUnicode(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != "dnd.npc_registry.validator.shape.v1" {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
|
||||
}
|
||||
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||
@@ -74,6 +74,21 @@ func TestValidatorDoesNotMutateValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorUsesProposalGuidanceDuringNormalization(t *testing.T) {
|
||||
value := validNPCRegistry()
|
||||
value.NPCs[0].ID = "npc:sha256:opaque"
|
||||
value.NPCs[0].Name = ""
|
||||
req := requestWithValue(value)
|
||||
req.Stage = string(pipeline.StageNormalize)
|
||||
result, err := New(Options{}).Validate(context.Background(), req)
|
||||
if err != nil || result.Approved {
|
||||
t.Fatalf("Validate() = %#v, %v; want rejection", result, err)
|
||||
}
|
||||
if !strings.Contains(result.CorrectionGuidance, "duplicate-group proposals") || strings.Contains(result.CorrectionGuidance, value.NPCs[0].ID) || strings.Contains(result.CorrectionGuidance, "npcs[") {
|
||||
t.Fatalf("CorrectionGuidance = %q, want proposal guidance without IDs or operator paths", result.CorrectionGuidance)
|
||||
}
|
||||
}
|
||||
|
||||
func requestWithValue(value dnd.NPCRegistry) contracts.TypedValidationRequest[dnd.NPCRegistry] {
|
||||
return contracts.TypedValidationRequest[dnd.NPCRegistry]{Value: value}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcregistry/shape"
|
||||
)
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/npc-registry/source_refs"
|
||||
ReasonCode = "invalid_npc_source_refs"
|
||||
policy = "dnd.npc_registry.validator.source_refs.v2"
|
||||
policy = "dnd.npc_registry.validator.source_refs.v3"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -41,56 +42,40 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
var coverage *chunkCoverage
|
||||
var coverage shared.ChunkCoverage
|
||||
if req.Stage == string(pipeline.StageExtract) {
|
||||
coverage = newChunkCoverage(req.Chunk)
|
||||
coverage = shared.NewChunkCoverage(req.Chunk)
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
normalization := req.Stage == string(pipeline.StageNormalize)
|
||||
validRangeRule := "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first."
|
||||
guidancePrefix := "Correct every rejected NPC citation and return the complete replacement NPC registry"
|
||||
if normalization {
|
||||
validRangeRule = "Revise the duplicate-group proposals so every selected canonical NPC preserves valid transcript evidence; do not reproduce application IDs."
|
||||
guidancePrefix = "Correct the duplicate-group proposals and return the complete replacement proposal response"
|
||||
}
|
||||
for npcIndex, npc := range req.Value.NPCs {
|
||||
for refIndex, ref := range npc.SourceRefs {
|
||||
record := fmt.Sprintf("Affected NPC %s, citing %s.", diagnostics.Quote(npc.Name), diagnostics.SourceRefRange(ref))
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
issues = append(issues, fmt.Sprintf("npcs[%d].source_refs[%d]: %s", npcIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
corrections.Add("valid-range", validRangeRule, record)
|
||||
continue
|
||||
}
|
||||
if coverage != nil && !coverage.contains(req.Source, ref) {
|
||||
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||
issues = append(issues, fmt.Sprintf("npcs[%d].source_refs[%d]: source reference is outside the current extraction chunk", npcIndex, refIndex))
|
||||
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return rejection(diagnostics.Aggregate("invalid NPC source references", issues)), nil
|
||||
}
|
||||
|
||||
type chunkCoverage struct {
|
||||
sourceID string
|
||||
unitIDs map[int]struct{}
|
||||
}
|
||||
|
||||
func newChunkCoverage(chunk *source.Chunk) *chunkCoverage {
|
||||
coverage := &chunkCoverage{sourceID: chunk.SourceID, unitIDs: make(map[int]struct{}, len(chunk.Units))}
|
||||
for _, unit := range chunk.Units {
|
||||
coverage.unitIDs[unit.ID] = struct{}{}
|
||||
}
|
||||
return coverage
|
||||
}
|
||||
|
||||
func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool {
|
||||
if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID {
|
||||
return false
|
||||
}
|
||||
start, startOK := source.UnitIndex(doc, ref.StartUnitID)
|
||||
end, endOK := source.UnitIndex(doc, ref.EndUnitID)
|
||||
if !startOK || !endOK || start > end {
|
||||
return false
|
||||
}
|
||||
for position := start; position <= end; position++ {
|
||||
if _, found := coverage.unitIDs[doc.Units[position].ID]; !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return rejection(
|
||||
diagnostics.Aggregate("invalid NPC source references", issues),
|
||||
corrections.Guidance(guidancePrefix),
|
||||
), nil
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
@@ -116,6 +101,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
||||
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
|
||||
func rejection(message string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return registry NPCs whose source references identify valid transcript ranges within the supplied extraction chunk and directly support each proper NPC name."}
|
||||
func rejection(message, guidance string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,20 @@ func TestValidatorDefersMalformedShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorUsesProposalGuidanceDuringNormalization(t *testing.T) {
|
||||
value := validNPCRegistry()
|
||||
value.NPCs[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}
|
||||
req := requestWithValue(validDocument(), value)
|
||||
req.Stage = string(pipeline.StageNormalize)
|
||||
result, err := New(Options{}).Validate(context.Background(), req)
|
||||
if err != nil || result.Approved {
|
||||
t.Fatalf("Validate() = %#v, %v; want rejection", result, err)
|
||||
}
|
||||
if !strings.Contains(result.CorrectionGuidance, "duplicate-group proposals") || !strings.Contains(result.CorrectionGuidance, "Mira Thorn") || strings.Contains(result.CorrectionGuidance, "npcs[") {
|
||||
t.Fatalf("CorrectionGuidance = %q, want contextual proposal guidance", result.CorrectionGuidance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorBoundsDiagnosticsAndHandlesMissingDocument(t *testing.T) {
|
||||
value := validNPCRegistry()
|
||||
value.NPCs[0].SourceRefs = make([]source.SourceRef, 24)
|
||||
@@ -86,7 +100,7 @@ func TestValidatorBoundsDiagnosticsAndHandlesMissingDocument(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != "dnd.npc_registry.validator.source_refs.v2" {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
|
||||
}
|
||||
if Spec().ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/scene-descriptions/shape"
|
||||
ReasonCode = "invalid_scene_description_shape"
|
||||
policy = "dnd.scene_descriptions.validator.shape.v1"
|
||||
policy = "dnd.scene_descriptions.validator.shape.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -34,8 +34,14 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SceneDescriptionList]) (contracts.ValidationResult, error) {
|
||||
if err := ValidateForStage(req.Value, req.Stage); err != nil {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error(), CorrectionGuidance: "Return a complete scene-description list with the required number of scenes, supported scene kinds, nonblank titles and summaries, and valid source references."}, nil
|
||||
issues, corrections := assess(req.Value, req.Stage == string(pipeline.StageExtract))
|
||||
if len(issues) != 0 {
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("invalid scene description shape", issues),
|
||||
CorrectionGuidance: corrections.Guidance("Correct the rejected scene description and return the complete replacement response"),
|
||||
}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
@@ -47,38 +53,53 @@ func ValidateForStage(value dnd.SceneDescriptionList, stage string) error {
|
||||
}
|
||||
|
||||
func validate(value dnd.SceneDescriptionList, exactlyOne bool) error {
|
||||
issues := make([]string, 0)
|
||||
if value.Scenes == nil {
|
||||
issues = append(issues, "scenes must be present")
|
||||
} else if len(value.Scenes) == 0 {
|
||||
issues = append(issues, "scenes must not be empty")
|
||||
} else if exactlyOne && len(value.Scenes) != 1 {
|
||||
issues = append(issues, "extraction must contain exactly one scene")
|
||||
}
|
||||
for index, scene := range value.Scenes {
|
||||
prefix := fmt.Sprintf("scenes[%d]", index)
|
||||
if strings.TrimSpace(scene.ID) == "" || scene.ID != strings.TrimSpace(scene.ID) {
|
||||
issues = append(issues, prefix+".id must be non-empty and trimmed")
|
||||
}
|
||||
if !ValidKind(scene.Kind) {
|
||||
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(scene.Kind)))
|
||||
}
|
||||
if strings.TrimSpace(scene.Title) == "" || scene.Title != strings.TrimSpace(scene.Title) {
|
||||
issues = append(issues, prefix+".title must be non-empty and trimmed")
|
||||
}
|
||||
if strings.TrimSpace(scene.Summary) == "" || scene.Summary != strings.TrimSpace(scene.Summary) {
|
||||
issues = append(issues, prefix+".summary must be non-empty and trimmed")
|
||||
}
|
||||
if strings.TrimSpace(scene.SourceRef.SourceID) == "" || scene.SourceRef.SourceID != strings.TrimSpace(scene.SourceRef.SourceID) || scene.SourceRef.StartUnitID <= 0 || scene.SourceRef.EndUnitID <= 0 {
|
||||
issues = append(issues, prefix+".source_ref must have a trimmed source ID and positive unit IDs")
|
||||
}
|
||||
}
|
||||
issues, _ := assess(value, exactlyOne)
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid scene description shape", issues))
|
||||
}
|
||||
|
||||
func assess(value dnd.SceneDescriptionList, exactlyOne bool) ([]string, diagnostics.Corrections) {
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
if value.Scenes == nil {
|
||||
issues = append(issues, "scenes must be present")
|
||||
corrections.Add("scene", "Return one scene description for the supplied extraction chunk.", "")
|
||||
} else if len(value.Scenes) == 0 {
|
||||
issues = append(issues, "scenes must not be empty")
|
||||
corrections.Add("scene", "Return one scene description for the supplied extraction chunk.", "")
|
||||
} else if exactlyOne && len(value.Scenes) != 1 {
|
||||
issues = append(issues, "extraction must contain exactly one scene")
|
||||
corrections.Add("scene", "Return exactly one scene description for the supplied extraction chunk.", "")
|
||||
}
|
||||
for index, scene := range value.Scenes {
|
||||
prefix := fmt.Sprintf("scenes[%d]", index)
|
||||
record := fmt.Sprintf("Affected scene titled %s with kind %s, citing %s.", diagnostics.Quote(strings.TrimSpace(scene.Title)), diagnostics.Quote(string(scene.Kind)), diagnostics.SourceRefRange(scene.SourceRef))
|
||||
if strings.TrimSpace(scene.ID) == "" || scene.ID != strings.TrimSpace(scene.ID) {
|
||||
issues = append(issues, prefix+".id must be non-empty and trimmed")
|
||||
corrections.Add("scene", "Return exactly one scene description for the supplied extraction chunk.", record)
|
||||
}
|
||||
if !ValidKind(scene.Kind) {
|
||||
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(scene.Kind)))
|
||||
corrections.Add("kind", "Set `kind` to exactly one of `combat`, `narrative`, `recap`, or `meta`.", record)
|
||||
}
|
||||
if strings.TrimSpace(scene.Title) == "" || scene.Title != strings.TrimSpace(scene.Title) {
|
||||
issues = append(issues, prefix+".title must be non-empty and trimmed")
|
||||
corrections.Add("title", "Provide a concise, nonblank, trimmed title grounded in the supplied scene transcript.", record)
|
||||
}
|
||||
if strings.TrimSpace(scene.Summary) == "" || scene.Summary != strings.TrimSpace(scene.Summary) {
|
||||
issues = append(issues, prefix+".summary must be non-empty and trimmed")
|
||||
corrections.Add("summary", "Provide a concise, nonblank, trimmed summary grounded in the supplied scene transcript.", record)
|
||||
}
|
||||
if strings.TrimSpace(scene.SourceRef.SourceID) == "" || scene.SourceRef.SourceID != strings.TrimSpace(scene.SourceRef.SourceID) || scene.SourceRef.StartUnitID <= 0 || scene.SourceRef.EndUnitID <= 0 {
|
||||
issues = append(issues, prefix+".source_ref must have a trimmed source ID and positive unit IDs")
|
||||
corrections.Add("scene", "Return exactly one scene description for the supplied extraction chunk.", record)
|
||||
}
|
||||
}
|
||||
return issues, corrections
|
||||
}
|
||||
|
||||
func ValidKind(value dnd.SceneKind) bool {
|
||||
switch value {
|
||||
case dnd.SceneKindCombat, dnd.SceneKindNarrative, dnd.SceneKindRecap, dnd.SceneKindMeta:
|
||||
|
||||
@@ -5,19 +5,19 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "extract/dnd/spells/catalog"
|
||||
ReasonCode = "unknown_spell"
|
||||
maxIssues = 20
|
||||
maxDisplayedNameRunes = 128
|
||||
maxMessageBytes = 4096
|
||||
Key = "extract/dnd/spells/catalog"
|
||||
ReasonCode = "unknown_spell"
|
||||
policy = "dnd.spells.validator.catalog.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -54,7 +54,10 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{{Name: "effective_catalog", Value: v.catalog.Digest()}}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{Name: "policy", Value: policy},
|
||||
{Name: "effective_catalog", Value: v.catalog.Digest()},
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
||||
@@ -69,7 +72,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
continue
|
||||
}
|
||||
if _, ok := v.catalog.Lookup(name); !ok {
|
||||
unknown = append(unknown, unknownSpell{index: index, name: name})
|
||||
unknown = append(unknown, unknownSpell{index: index, name: name, caster: spell.Caster, sourceRefs: spell.SourceRefs})
|
||||
}
|
||||
}
|
||||
if len(unknown) == 0 {
|
||||
@@ -79,42 +82,29 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
}
|
||||
|
||||
type unknownSpell struct {
|
||||
index int
|
||||
name string
|
||||
index int
|
||||
name string
|
||||
caster string
|
||||
sourceRefs []source.SourceRef
|
||||
}
|
||||
|
||||
func rejection(unknown []unknownSpell) contracts.ValidationResult {
|
||||
limit := len(unknown)
|
||||
if limit > maxIssues {
|
||||
limit = maxIssues
|
||||
issues := make([]string, 0, len(unknown))
|
||||
var corrections diagnostics.Corrections
|
||||
for _, item := range unknown {
|
||||
issues = append(issues, fmt.Sprintf("spell_casts[%d].spell %s", item.index, diagnostics.Quote(item.name)))
|
||||
corrections.Add(
|
||||
"recognized-spell",
|
||||
"Use a recognized D&D spell name supported by the transcript, or omit the record when the evidence does not establish a spell cast.",
|
||||
fmt.Sprintf("Affected spell %s by caster %s %s.", diagnostics.Quote(item.name), diagnostics.Quote(strings.TrimSpace(item.caster)), diagnostics.SourceRange(item.sourceRefs)),
|
||||
)
|
||||
}
|
||||
issues := make([]string, 0, limit)
|
||||
for _, item := range unknown[:limit] {
|
||||
issue := fmt.Sprintf("spell_casts[%d].spell %q", item.index, truncateDisplayedName(item.name))
|
||||
candidate := rejectionMessage(append(issues, issue), len(unknown)-len(issues)-1)
|
||||
if len(candidate) > maxMessageBytes {
|
||||
break
|
||||
}
|
||||
issues = append(issues, issue)
|
||||
return contracts.ValidationResult{
|
||||
Approved: false,
|
||||
ReasonCode: ReasonCode,
|
||||
Message: diagnostics.Aggregate("unknown spell names", issues),
|
||||
CorrectionGuidance: corrections.Guidance("Correct every unrecognized spell and return the complete replacement spell-cast list"),
|
||||
}
|
||||
message := rejectionMessage(issues, len(unknown)-len(issues))
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Use recognized D&D spell names supported by the supplied transcript evidence, and omit any candidate that is not a spell cast."}
|
||||
}
|
||||
|
||||
func truncateDisplayedName(name string) string {
|
||||
runes := []rune(name)
|
||||
if len(runes) <= maxDisplayedNameRunes {
|
||||
return name
|
||||
}
|
||||
return string(runes[:maxDisplayedNameRunes-1]) + "…"
|
||||
}
|
||||
|
||||
func rejectionMessage(issues []string, omitted int) string {
|
||||
message := "unknown spell names: " + strings.Join(issues, ", ")
|
||||
if omitted > 0 {
|
||||
message += fmt.Sprintf("; %d additional issue(s) omitted", omitted)
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
|
||||
@@ -2,11 +2,9 @@ package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
@@ -36,7 +34,7 @@ func TestValidatorCheckpointFingerprintUsesEffectiveCatalogDigest(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
fingerprints := validator.CheckpointFingerprints()
|
||||
if len(fingerprints) != 1 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != validator.catalog.Digest() {
|
||||
if len(fingerprints) != 2 || fingerprints[0].Name != "policy" || fingerprints[0].Value != policy || fingerprints[1].Name != "effective_catalog" || fingerprints[1].Value != validator.catalog.Digest() {
|
||||
t.Fatalf("checkpoint fingerprints = %#v, want effective catalog digest %q", fingerprints, validator.catalog.Digest())
|
||||
}
|
||||
}
|
||||
@@ -60,59 +58,13 @@ func TestValidatorRejectsMultipleUnknownCastsInStableOrder(t *testing.T) {
|
||||
if want := `spell_casts[0].spell "Unknown First", spell_casts[2].spell "Unknown Second"`; !strings.Contains(result.Message, want) {
|
||||
t.Fatalf("message = %q, want %q", result.Message, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorBoundsUnknownCastMessage(t *testing.T) {
|
||||
value := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 22)}
|
||||
for index := range value.SpellCasts {
|
||||
value.SpellCasts[index] = validCast(fmt.Sprintf("Unknown Spell %02d", index))
|
||||
for _, want := range []string{"Unknown First", "Unknown Second", "source unit 1", "complete replacement"} {
|
||||
if !strings.Contains(result.CorrectionGuidance, want) {
|
||||
t.Fatalf("CorrectionGuidance = %q, want %q", result.CorrectionGuidance, want)
|
||||
}
|
||||
}
|
||||
validator, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v, want nil", err)
|
||||
}
|
||||
result, err := validator.Validate(context.Background(), validationRequest(value))
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v, want nil", err)
|
||||
}
|
||||
if result.Approved || result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("Validate() = %#v, want unknown-spell rejection", result)
|
||||
}
|
||||
if got := strings.Count(result.Message, "spell_casts["); got != maxIssues {
|
||||
t.Fatalf("message includes %d issues, want %d: %q", got, maxIssues, result.Message)
|
||||
}
|
||||
if !strings.Contains(result.Message, "2 additional issue(s) omitted") || strings.Contains(result.Message, "Unknown Spell 21") {
|
||||
t.Fatalf("message = %q, want bounded diagnostics", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorBoundsUnknownSpellNamesAndTotalMessage(t *testing.T) {
|
||||
longName := strings.Repeat("火", maxDisplayedNameRunes+100) + "\n\t"
|
||||
value := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, maxIssues)}
|
||||
for index := range value.SpellCasts {
|
||||
value.SpellCasts[index] = validCast(fmt.Sprintf("%s-%d", longName, index))
|
||||
}
|
||||
validator, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := validator.Validate(context.Background(), validationRequest(value))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Approved || len([]byte(result.Message)) > maxMessageBytes || !strings.Contains(result.Message, "…") {
|
||||
t.Fatalf("message length/content = %d/%q, want bounded message with truncation", len([]byte(result.Message)), result.Message)
|
||||
}
|
||||
if !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||
t.Fatalf("message = %q, want byte-budget omitted count", result.Message)
|
||||
}
|
||||
displayed := strings.Count(result.Message, "spell_casts[")
|
||||
wantOmitted := fmt.Sprintf("%d additional issue(s) omitted", len(value.SpellCasts)-displayed)
|
||||
if !strings.Contains(result.Message, wantOmitted) {
|
||||
t.Fatalf("message = %q, want omitted count %q", result.Message, wantOmitted)
|
||||
}
|
||||
if !utf8.ValidString(result.Message) {
|
||||
t.Fatal("bounded message is not valid UTF-8")
|
||||
if strings.Contains(result.CorrectionGuidance, "spell_casts[") || strings.Contains(result.CorrectionGuidance, ReasonCode) {
|
||||
t.Fatalf("CorrectionGuidance exposed internal diagnostics: %q", result.CorrectionGuidance)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "extract/dnd/spells/shape"
|
||||
ReasonCode = "invalid_spell_shape"
|
||||
policy = "dnd.spells.validator.shape.v1"
|
||||
policy = "dnd.spells.validator.shape.v2"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -31,28 +32,69 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
}
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
||||
if err := Validate(req.Value); err != nil {
|
||||
return rejection(err.Error()), nil
|
||||
assessment := assess(req.Value)
|
||||
if len(assessment.issues) != 0 {
|
||||
return rejection(assessment.message(), assessment.corrections.Guidance("Correct every rejected spell cast and return the complete replacement list")), nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func Validate(value dnd.SpellList) error {
|
||||
assessment := assess(value)
|
||||
if len(assessment.issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", assessment.message())
|
||||
}
|
||||
|
||||
type validationAssessment struct {
|
||||
issues []string
|
||||
corrections diagnostics.Corrections
|
||||
}
|
||||
|
||||
func assess(value dnd.SpellList) validationAssessment {
|
||||
var assessment validationAssessment
|
||||
if value.SpellCasts == nil {
|
||||
return fmt.Errorf("spell_casts must be present")
|
||||
assessment.issues = append(assessment.issues, "spell_casts must be present")
|
||||
assessment.corrections.Add("list", "Return a `spell_casts` array; use an empty array when the transcript establishes no spell casts.", "")
|
||||
return assessment
|
||||
}
|
||||
for index, spell := range value.SpellCasts {
|
||||
context := spellContext(spell)
|
||||
if strings.TrimSpace(spell.Caster) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].caster must not be empty", index)
|
||||
assessment.issues = append(assessment.issues, fmt.Sprintf("spell_casts[%d].caster must not be empty", index))
|
||||
assessment.corrections.Add("caster", "Provide the contextual caster name for every spell cast.", context)
|
||||
}
|
||||
if strings.TrimSpace(spell.Spell) == "" {
|
||||
return fmt.Errorf("spell_casts[%d].spell must not be empty", index)
|
||||
assessment.issues = append(assessment.issues, fmt.Sprintf("spell_casts[%d].spell must not be empty", index))
|
||||
assessment.corrections.Add("spell", "Provide the transcript-supported spell name for every spell cast.", context)
|
||||
}
|
||||
if len(spell.SourceRefs) == 0 {
|
||||
return fmt.Errorf("spell_casts[%d].source_refs must not be empty", index)
|
||||
assessment.issues = append(assessment.issues, fmt.Sprintf("spell_casts[%d].source_refs must not be empty", index))
|
||||
assessment.corrections.Add("source-refs", "Provide at least one transcript source range that directly supports every spell cast.", context)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return assessment
|
||||
}
|
||||
|
||||
func (assessment validationAssessment) message() string {
|
||||
return diagnostics.Aggregate("invalid spell shape", assessment.issues)
|
||||
}
|
||||
|
||||
func spellContext(spell dnd.SpellCast) string {
|
||||
name := strings.TrimSpace(spell.Spell)
|
||||
if name == "" {
|
||||
name = "blank spell name"
|
||||
} else {
|
||||
name = "spell " + diagnostics.Quote(name)
|
||||
}
|
||||
caster := strings.TrimSpace(spell.Caster)
|
||||
if caster == "" {
|
||||
caster = "blank caster name"
|
||||
} else {
|
||||
caster = "caster " + diagnostics.Quote(caster)
|
||||
}
|
||||
return "Affected cast: " + name + " with " + caster + " " + diagnostics.SourceRange(spell.SourceRefs) + "."
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
@@ -74,6 +116,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
||||
return Options{}, nil
|
||||
}
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
func rejection(message string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return a complete spell-cast list with a nonblank spell name and caster plus valid source references for every cast."}
|
||||
func rejection(message, guidance string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package shape
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
@@ -54,6 +55,30 @@ func TestValidatorRejectsMissingRequiredSpellFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorReportsAllSpellDefectsWithContextualGuidance(t *testing.T) {
|
||||
value := dnd.SpellList{SpellCasts: []dnd.SpellCast{
|
||||
{Caster: "", Spell: "Fire Bolt", SourceRefs: refsAt(4)},
|
||||
{Caster: "Aria", Spell: "", SourceRefs: nil},
|
||||
}}
|
||||
result, err := New(Options{}).Validate(context.Background(), requestWithValue(value))
|
||||
if err != nil || result.Approved {
|
||||
t.Fatalf("Validate() = %#v, %v; want rejection", result, err)
|
||||
}
|
||||
for _, want := range []string{"spell_casts[0].caster", "spell_casts[1].spell", "spell_casts[1].source_refs"} {
|
||||
if !strings.Contains(result.Message, want) {
|
||||
t.Fatalf("Message = %q, want %q", result.Message, want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"Fire Bolt", "source unit 4", "Aria", "complete replacement"} {
|
||||
if !strings.Contains(result.CorrectionGuidance, want) {
|
||||
t.Fatalf("CorrectionGuidance = %q, want %q", result.CorrectionGuidance, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(result.CorrectionGuidance, "spell_casts[") {
|
||||
t.Fatalf("CorrectionGuidance exposed operator path: %q", result.CorrectionGuidance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
|
||||
@@ -77,3 +102,7 @@ func requestWithValue(value dnd.SpellList) contracts.TypedValidationRequest[dnd.
|
||||
func validSpellList() dnd.SpellList {
|
||||
return dnd.SpellList{SpellCasts: []dnd.SpellCast{{Caster: "Aria", Spell: "Cure Wounds", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
|
||||
}
|
||||
|
||||
func refsAt(unitID int) []source.SourceRef {
|
||||
return []source.SourceRef{{SourceID: "session", StartUnitID: unitID, EndUnitID: unitID}}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||
)
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
const (
|
||||
Key = "extract/dnd/spells/source_refs"
|
||||
ReasonCode = "invalid_source_refs"
|
||||
policy = "dnd.spells.validator.source_refs.v2"
|
||||
policy = "dnd.spells.validator.source_refs.v3"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
@@ -40,57 +41,35 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
var coverage *chunkCoverage
|
||||
var coverage shared.ChunkCoverage
|
||||
if req.Stage == string(pipeline.StageExtract) {
|
||||
coverage = newChunkCoverage(req.Chunk)
|
||||
coverage = shared.NewChunkCoverage(req.Chunk)
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
var corrections diagnostics.Corrections
|
||||
for spellIndex, spell := range req.Value.SpellCasts {
|
||||
for refIndex, ref := range spell.SourceRefs {
|
||||
record := fmt.Sprintf("Affected spell %s by caster %s, citing %s.", diagnostics.Quote(spell.Spell), diagnostics.Quote(spell.Caster), diagnostics.SourceRefRange(ref))
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: %s", spellIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
corrections.Add("valid-range", "Use positive source range endpoints that occur in the supplied transcript, with the earlier unit first.", record)
|
||||
continue
|
||||
}
|
||||
if coverage != nil && !coverage.contains(req.Source, ref) {
|
||||
if req.Stage == string(pipeline.StageExtract) && !coverage.Contains(index, req.Source, ref) {
|
||||
issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: source reference is outside the current extraction chunk", spellIndex, refIndex))
|
||||
corrections.Add("chunk-range", "Use only source ranges wholly contained in the supplied extraction chunk.", record)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(issues) > 0 {
|
||||
return rejection(diagnostics.Aggregate("invalid spell source references", issues)), nil
|
||||
return rejection(
|
||||
diagnostics.Aggregate("invalid spell source references", issues),
|
||||
corrections.Guidance("Correct every rejected spell citation and return the complete replacement spell-cast list"),
|
||||
), nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
type chunkCoverage struct {
|
||||
sourceID string
|
||||
unitIDs map[int]struct{}
|
||||
}
|
||||
|
||||
func newChunkCoverage(chunk *source.Chunk) *chunkCoverage {
|
||||
coverage := &chunkCoverage{sourceID: chunk.SourceID, unitIDs: make(map[int]struct{}, len(chunk.Units))}
|
||||
for _, unit := range chunk.Units {
|
||||
coverage.unitIDs[unit.ID] = struct{}{}
|
||||
}
|
||||
return coverage
|
||||
}
|
||||
|
||||
func (coverage *chunkCoverage) contains(doc *source.SourceDocument, ref source.SourceRef) bool {
|
||||
if coverage == nil || doc == nil || ref.SourceID != coverage.sourceID {
|
||||
return false
|
||||
}
|
||||
start, startOK := source.UnitIndex(doc, ref.StartUnitID)
|
||||
end, endOK := source.UnitIndex(doc, ref.EndUnitID)
|
||||
if !startOK || !endOK || start > end {
|
||||
return false
|
||||
}
|
||||
for position := start; position <= end; position++ {
|
||||
if _, found := coverage.unitIDs[doc.Units[position].ID]; !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
@@ -110,6 +89,6 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
||||
return Options{}, nil
|
||||
}
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
func rejection(message string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: "Return spell casts whose source references identify valid transcript ranges within the supplied extraction chunk and directly support the named spell and caster."}
|
||||
func rejection(message, guidance string) contracts.ValidationResult {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: message, CorrectionGuidance: guidance}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,14 @@ func TestValidatorRejectsInvalidSourceRefs(t *testing.T) {
|
||||
if result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("ReasonCode = %q, want %q", result.ReasonCode, ReasonCode)
|
||||
}
|
||||
for _, want := range []string{"Cure Wounds", "Aria", "source unit 99", "complete replacement"} {
|
||||
if !strings.Contains(result.CorrectionGuidance, want) {
|
||||
t.Fatalf("CorrectionGuidance = %q, want %q", result.CorrectionGuidance, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(result.CorrectionGuidance, "spell_casts[") || strings.Contains(result.CorrectionGuidance, ReasonCode) {
|
||||
t.Fatalf("CorrectionGuidance exposed internal diagnostics: %q", result.CorrectionGuidance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRejectsMissingSourceDocument(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user