Adopt registry-backed NPC occurrence artifacts

This commit is contained in:
2026-08-05 18:55:58 +00:00
parent 3f4a1f2647
commit 5e2ccffc0f
42 changed files with 676 additions and 528 deletions

View File

@@ -1,7 +1,8 @@
Extract Dungeons & Dragons NPC interaction occurrences from the supplied Extract Dungeons & Dragons NPC interaction occurrences from the supplied
transcript. Include an occurrence only when the transcript establishes one transcript. Include an occurrence only when the transcript establishes one
supplied NPC, one interaction kind, and a coherent passage supporting both. supplied NPC, one interaction kind, and a coherent passage supporting both.
Use only names from the supplied NPC registry. Use the exact `npc_id` and matching `name` pair from the supplied NPC registry;
never invent an ID or substitute a similar name.
Do not summarize, infer relationships, sentiment, factions, motives, aliases, Do not summarize, infer relationships, sentiment, factions, motives, aliases,
or persistent state. Do not identify player characters, anonymous groups, or or persistent state. Do not identify player characters, anonymous groups, or

View File

@@ -41,5 +41,5 @@ messages:
output: output:
format: json format: json
validation_mode: json_schema validation_mode: json_schema
schema_path: dnd_npc_interactions_llm.v1.json schema_path: dnd_npc_occurrences_llm.v1.json
repair_attempts: 0 repair_attempts: 0

View File

@@ -1,17 +1,20 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.npc_interactions.llm", "$id": "notarius.dnd.npc_occurrences.llm",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["interactions"], "required": ["occurrences"],
"properties": { "properties": {
"interactions": { "occurrences": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["name", "kind", "source_refs"], "required": ["npc_id", "name", "kind", "source_refs"],
"properties": { "properties": {
"npc_id": {
"type": "string"
},
"name": { "name": {
"type": "string" "type": "string"
}, },

View File

@@ -9,22 +9,23 @@ or summarize the session.
| Property | Value | | Property | Value |
| --- | --- | | --- | --- |
| Artifact kind | `dnd/npc-interaction-list` | | Artifact kind | `dnd/npc-occurrence-list` |
| Schema ID | `notarius.dnd.npc_interactions` | | Schema ID | `notarius.dnd.npc_occurrences` |
| Schema name | `notarius_dnd_npc_interactions_v1` | | Schema name | `notarius_dnd_npc_occurrences_v1` |
| Schema version | `v1` | | Schema version | `v1` |
| Media type | `application/json` | | Media type | `application/json` |
`v1` is a strict JSON object with required `interactions`; the array may be `v1` is a strict JSON object with required `occurrences`; the array may be
empty. Interaction and source-reference objects reject unknown fields. An empty. Occurrence and source-reference objects reject unknown fields. An
incompatible shape change requires a new schema version. incompatible shape change requires a new schema version.
## Wire shape ## Wire shape
Each interaction has these required fields: Each occurrence has these required fields:
| Field | Contract | | Field | Contract |
| --- | --- | | --- | --- |
| `npc_id` | Exact durable ID from the required NPC registry. |
| `name` | Non-empty canonical display name from the required NPC registry. | | `name` | Non-empty canonical display name from the required NPC registry. |
| `kind` | One of the interaction categories below. | | `kind` | One of the interaction categories below. |
| `source_refs` | One or more transcript evidence ranges. | | `source_refs` | One or more transcript evidence ranges. |
@@ -36,8 +37,9 @@ for an interaction is confined to its accepted chunk.
```json ```json
{ {
"interactions": [ "occurrences": [
{ {
"npc_id": "npc:sha256:example",
"name": "Mira Thorn", "name": "Mira Thorn",
"kind": "dialogue", "kind": "dialogue",
"source_refs": [ "source_refs": [
@@ -65,12 +67,14 @@ for uncertain classification.
## Identity, evidence, and order ## Identity, evidence, and order
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves `name`. The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves
Registry references are provenance only and never replace an interaction's own the exact `{npc_id, name}` pair. Unknown IDs and names that do not match their
evidence. Normalization canonicalizes recognized registry names, orders and ID are rejected; normalization does not repair names by similarity. Registry
references are provenance only and never replace an interaction's own evidence.
Normalization validates the exact pair, orders and
deduplicates exact source references, then orders interactions by valid source deduplicates exact source references, then orders interactions by valid source
chronology, NPC comparison identity, display name, kind, and reference sequence. chronology, NPC comparison identity, display name, kind, and reference sequence.
Only entries with the same canonical name, kind, and complete valid evidence Only entries with the same NPC ID, canonical name, kind, and complete valid evidence
sequence are collapsed; distinct categories or evidence remain separate. sequence are collapsed; distinct categories or evidence remain separate.
See the [combat-turn artifact](dnd-combat-turn-artifacts.md) for combat-action See the [combat-turn artifact](dnd-combat-turn-artifacts.md) for combat-action

View File

@@ -259,9 +259,20 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`) content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
case npcinteractions.PromptID: case npcinteractions.PromptID:
if combatScene { if combatScene {
content = []byte(`{"interactions":[{"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`) var registry struct {
NPCs []struct {
ID string `json:"id"`
} `json:"npcs"`
}
if err := json.Unmarshal(request.Inputs["npc_registry"].Content, &registry); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated NPC registry: %w", err)
}
if len(registry.NPCs) == 0 {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated NPC registry has no NPCs")
}
content = []byte(fmt.Sprintf(`{"occurrences":[{"npc_id":%q,"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`, registry.NPCs[0].ID))
} else { } else {
content = []byte(`{"interactions":[]}`) content = []byte(`{"occurrences":[]}`)
} }
case locationoccurrences.PromptID: case locationoccurrences.PromptID:
var registry struct { var registry struct {

View File

@@ -25,7 +25,7 @@ func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) {
t.Fatalf("resolved pipeline = %#v", resolved) t.Fatalf("resolved pipeline = %#v", resolved)
} }
lane := resolved.Steps[1].ArtifactLanes[0] lane := resolved.Steps[1].ArtifactLanes[0]
if lane.ArtifactKind != dnd.NPCInteractionListKind || lane.Extract.Module != interactionextract.Key || lane.Normalize.Module != interactionnormalize.Key { if lane.ArtifactKind != dnd.NPCOccurrenceListKind || lane.Extract.Module != interactionextract.Key || lane.Normalize.Module != interactionnormalize.Key {
t.Fatalf("interaction lane = %#v", lane) t.Fatalf("interaction lane = %#v", lane)
} }
for _, bindings := range [][]pipeline.ReferenceBinding{lane.ExtractReferences.Bindings, lane.NormalizeReferences.Bindings} { for _, bindings := range [][]pipeline.ReferenceBinding{lane.ExtractReferences.Bindings, lane.NormalizeReferences.Bindings} {
@@ -38,7 +38,7 @@ func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) {
} }
catalog := catalogFromRegistries(components.registries) catalog := catalogFromRegistries(components.registries)
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCInteractionListKind) codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.NPCOccurrenceListKind)
if !ok || codecSpec.Schema.ID != interactioncodec.SchemaID || codecSpec.Schema.Version != interactioncodec.SchemaVersion { if !ok || codecSpec.Schema.ID != interactioncodec.SchemaID || codecSpec.Schema.Version != interactioncodec.SchemaVersion {
t.Fatalf("NPC interaction codec spec = %#v", codecSpec) t.Fatalf("NPC interaction codec spec = %#v", codecSpec)
} }

View File

@@ -1,17 +1,21 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.npc_interactions", "$id": "notarius.dnd.npc_occurrences",
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["interactions"], "required": ["occurrences"],
"properties": { "properties": {
"interactions": { "occurrences": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["name", "kind", "source_refs"], "required": ["npc_id", "name", "kind", "source_refs"],
"properties": { "properties": {
"npc_id": {
"type": "string",
"minLength": 1
},
"name": { "name": {
"type": "string", "type": "string",
"minLength": 1 "minLength": 1

View File

@@ -1,4 +1,4 @@
// Package npcinteractions encodes durable D&D NPC interaction artifacts. // Package npcinteractions encodes durable D&D NPC occurrence artifacts.
package npcinteractions package npcinteractions
import ( import (
@@ -12,25 +12,25 @@ import (
) )
const ( const (
SchemaID = "notarius.dnd.npc_interactions" SchemaID = "notarius.dnd.npc_occurrences"
SchemaName = "notarius_dnd_npc_interactions_v1" SchemaName = "notarius_dnd_npc_occurrences_v1"
SchemaVersion = "v1" SchemaVersion = "v1"
MediaType = "application/json" MediaType = "application/json"
) )
//go:embed assets/schemas/dnd_npc_interactions.v1.json //go:embed assets/schemas/dnd_npc_occurrences.v1.json
var schemaAssets embed.FS var schemaAssets embed.FS
var _ contracts.ArtifactCodec[dnd.NPCInteractionList] = (*Codec)(nil) var _ contracts.ArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil)
type Codec struct{} type Codec struct{}
func New() *Codec { return &Codec{} } func New() *Codec { return &Codec{} }
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.NPCInteractionListKind } func (c *Codec) Kind() contracts.ArtifactKind { return dnd.NPCOccurrenceListKind }
func (c *Codec) Schema() contracts.ArtifactSchema { func (c *Codec) Schema() contracts.ArtifactSchema {
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npc_interactions.v1.json") raw, err := schemaAssets.ReadFile("assets/schemas/dnd_npc_occurrences.v1.json")
if err != nil { if err != nil {
return contracts.ArtifactSchema{} return contracts.ArtifactSchema{}
} }
@@ -44,56 +44,59 @@ func (c *Codec) Schema() contracts.ArtifactSchema {
func (c *Codec) MediaType() string { return MediaType } func (c *Codec) MediaType() string { return MediaType }
func (c *Codec) Metadata(value dnd.NPCInteractionList) map[string]any { func (c *Codec) Metadata(value dnd.NPCOccurrenceList) map[string]any {
return map[string]any{"interaction_count": len(value.Interactions)} return map[string]any{"occurrence_count": len(value.Occurrences)}
} }
func (c *Codec) Encode(value dnd.NPCInteractionList) ([]byte, error) { func (c *Codec) Encode(value dnd.NPCOccurrenceList) ([]byte, error) {
if err := validate(value); err != nil { if err := validate(value); err != nil {
return nil, fmt.Errorf("encode dnd npc interaction list: %w", err) return nil, fmt.Errorf("encode dnd npc occurrence list: %w", err)
} }
return c.EncodeCandidate(value) return c.EncodeCandidate(value)
} }
// EncodeCandidate provides the durable representation before semantic // EncodeCandidate provides the durable representation before semantic
// validators have approved a value. // validators have approved a value.
func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) { func (c *Codec) EncodeCandidate(value dnd.NPCOccurrenceList) ([]byte, error) {
return candidatejson.EncodeCandidate("dnd npc interaction list", value) return candidatejson.EncodeCandidate("dnd npc occurrence list", value)
} }
func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) { func (c *Codec) Decode(content []byte) (dnd.NPCOccurrenceList, error) {
value, err := c.DecodeCandidate(content) value, err := c.DecodeCandidate(content)
if err != nil { if err != nil {
return dnd.NPCInteractionList{}, err return dnd.NPCOccurrenceList{}, err
} }
if err := validate(value); err != nil { if err := validate(value); err != nil {
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err) return dnd.NPCOccurrenceList{}, fmt.Errorf("decode dnd npc occurrence list: %w", err)
} }
return value, nil return value, nil
} }
// DecodeCandidate reads one strict durable JSON value before semantic // DecodeCandidate reads one strict durable JSON value before semantic
// validators have approved it. // validators have approved it.
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCInteractionList, error) { func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCOccurrenceList, error) {
return candidatejson.DecodeCandidate[dnd.NPCInteractionList]("dnd npc interaction list", content) return candidatejson.DecodeCandidate[dnd.NPCOccurrenceList]("dnd npc occurrence list", content)
} }
func validate(value dnd.NPCInteractionList) error { func validate(value dnd.NPCOccurrenceList) error {
if value.Interactions == nil { if value.Occurrences == nil {
return fmt.Errorf("interactions must be present") return fmt.Errorf("occurrences must be present")
} }
for index, interaction := range value.Interactions { for index, occurrence := range value.Occurrences {
prefix := fmt.Sprintf("interactions[%d]", index) prefix := fmt.Sprintf("occurrences[%d]", index)
if strings.TrimSpace(interaction.Name) == "" { if strings.TrimSpace(occurrence.NPCID) == "" {
return fmt.Errorf("%s.npc_id must not be empty", prefix)
}
if strings.TrimSpace(occurrence.Name) == "" {
return fmt.Errorf("%s.name must not be empty", prefix) return fmt.Errorf("%s.name must not be empty", prefix)
} }
if !validInteractionKind(interaction.Kind) { if !validOccurrenceKind(occurrence.Kind) {
return fmt.Errorf("%s.kind must be supported", prefix) return fmt.Errorf("%s.kind must be supported", prefix)
} }
if len(interaction.SourceRefs) == 0 { if len(occurrence.SourceRefs) == 0 {
return fmt.Errorf("%s.source_refs must contain at least one reference", prefix) return fmt.Errorf("%s.source_refs must contain at least one reference", prefix)
} }
for refIndex, ref := range interaction.SourceRefs { for refIndex, ref := range occurrence.SourceRefs {
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex) refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
if strings.TrimSpace(ref.SourceID) == "" { if strings.TrimSpace(ref.SourceID) == "" {
return fmt.Errorf("%s.source_id must not be empty", refPrefix) return fmt.Errorf("%s.source_id must not be empty", refPrefix)
@@ -109,14 +112,14 @@ func validate(value dnd.NPCInteractionList) error {
return nil return nil
} }
func validInteractionKind(value dnd.NPCInteractionKind) bool { func validOccurrenceKind(value dnd.NPCOccurrenceKind) bool {
switch value { switch value {
case dnd.NPCInteractionKindMentioned, case dnd.NPCOccurrenceKindMentioned,
dnd.NPCInteractionKindNoncombatPresence, dnd.NPCOccurrenceKindNoncombatPresence,
dnd.NPCInteractionKindDialogue, dnd.NPCOccurrenceKindDialogue,
dnd.NPCInteractionKindCombatAlly, dnd.NPCOccurrenceKindCombatAlly,
dnd.NPCInteractionKindCombatOpponent, dnd.NPCOccurrenceKindCombatOpponent,
dnd.NPCInteractionKindOther: dnd.NPCOccurrenceKindOther:
return true return true
default: default:
return false return false

View File

@@ -15,21 +15,21 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
) )
func validList() dnd.NPCInteractionList { func validList() dnd.NPCOccurrenceList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
{ {
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, NPCID: "npc:test-mira", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}},
}, },
{ {
Name: "Hooded Guard", Kind: dnd.NPCInteractionKindCombatOpponent, NPCID: "npc:test-guard", Name: "Hooded Guard", Kind: dnd.NPCOccurrenceKindCombatOpponent,
SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}}, SourceRefs: []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 3, EndUnitID: 3}},
}, },
}} }}
} }
func TestCodecMatchesMaintainedDurableFixture(t *testing.T) { func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
raw, err := os.ReadFile("testdata/dnd_npc_interactions.v1.json") raw, err := os.ReadFile("testdata/dnd_npc_occurrences.v1.json")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -57,7 +57,7 @@ func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) { func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
codec := New() codec := New()
schema := codec.Schema() schema := codec.Schema()
if codec.Kind() != dnd.NPCInteractionListKind || codec.MediaType() != MediaType { if codec.Kind() != dnd.NPCOccurrenceListKind || codec.MediaType() != MediaType {
t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType()) t.Fatalf("codec identity = %q/%q", codec.Kind(), codec.MediaType())
} }
if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) { if schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
@@ -67,11 +67,11 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil { if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
t.Fatal(err) t.Fatal(err)
} }
spec, ok := registry.Spec(dnd.NPCInteractionListKind) spec, ok := registry.Spec(dnd.NPCOccurrenceListKind)
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) { if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
t.Fatalf("registered spec = %#v, %t", spec, ok) t.Fatalf("registered spec = %#v, %t", spec, ok)
} }
if _, err := registry.Encode(dnd.NPCInteractionListKind, dnd.NPCRegistry{}); err == nil { if _, err := registry.Encode(dnd.NPCOccurrenceListKind, dnd.NPCRegistry{}); err == nil {
t.Fatal("Encode() error = nil, want exact type rejection") t.Fatal("Encode() error = nil, want exact type rejection")
} else { } else {
var typeErr *pipeline.ArtifactCodecTypeError var typeErr *pipeline.ArtifactCodecTypeError
@@ -83,17 +83,17 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *testing.T) { func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *testing.T) {
codec := New() codec := New()
empty := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}} empty := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{}}
content, err := codec.Encode(empty) content, err := codec.Encode(empty)
if err != nil || string(content) != `{"interactions":[]}` { if err != nil || string(content) != `{"occurrences":[]}` {
t.Fatalf("Encode() = %s, %v", content, err) t.Fatalf("Encode() = %s, %v", content, err)
} }
for _, candidate := range []dnd.NPCInteractionList{ for _, candidate := range []dnd.NPCOccurrenceList{
{}, {},
empty, empty,
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: nil}}}, {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: nil}}},
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}}, {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}},
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}}, {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}},
} { } {
content, err := codec.EncodeCandidate(candidate) content, err := codec.EncodeCandidate(candidate)
if err != nil || !json.Valid(content) { if err != nil || !json.Valid(content) {
@@ -107,13 +107,13 @@ func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *tes
} }
func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) { func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) {
validJSON := `{"interactions":[{"name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` validJSON := `{"occurrences":[{"npc_id":"npc:test-mira","name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
for _, test := range []struct{ name, raw, want string }{ for _, test := range []struct{ name, raw, want string }{
{"malformed", `{`, "decode dnd npc interaction list"}, {"malformed", `{`, "decode dnd npc occurrence list"},
{"unknown top-level", `{"interactions":[],"unexpected":true}`, "unknown field"}, {"unknown top-level", `{"occurrences":[],"unexpected":true}`, "unknown field"},
{"unknown interaction field", strings.Replace(validJSON, `"kind":"dialogue"`, `"kind":"dialogue","unexpected":true`, 1), "unknown field"}, {"unknown interaction field", strings.Replace(validJSON, `"kind":"dialogue"`, `"kind":"dialogue","unexpected":true`, 1), "unknown field"},
{"unknown source reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"}, {"unknown source reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
{"trailing", `{"interactions":[]} {}`, "multiple JSON values"}, {"trailing", `{"occurrences":[]} {}`, "multiple JSON values"},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) { if _, err := New().Decode([]byte(test.raw)); err == nil || !strings.Contains(err.Error(), test.want) {
@@ -126,16 +126,17 @@ func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) {
func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) { func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
value dnd.NPCInteractionList value dnd.NPCOccurrenceList
want string want string
}{ }{
{"nil interactions", dnd.NPCInteractionList{}, "interactions must be present"}, {"nil occurrences", dnd.NPCOccurrenceList{}, "occurrences must be present"},
{"blank name", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Name = " " }), "name must not be empty"}, {"blank NPC ID", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].NPCID = " " }), "npc_id must not be empty"},
{"unsupported kind", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Kind = "unsupported" }), "kind must be supported"}, {"blank name", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].Name = " " }), "name must not be empty"},
{"nil source refs", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs = nil }), "source_refs must contain"}, {"unsupported kind", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].Kind = "unsupported" }), "kind must be supported"},
{"empty source ID", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"}, {"nil source refs", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs = nil }), "source_refs must contain"},
{"non-positive start", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"}, {"empty source ID", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
{"non-positive end", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"}, {"non-positive start", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"},
{"non-positive end", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"},
} }
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -147,16 +148,16 @@ func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) {
} }
func TestCodecAcceptsEveryInteractionKind(t *testing.T) { func TestCodecAcceptsEveryInteractionKind(t *testing.T) {
for _, kind := range []dnd.NPCInteractionKind{ for _, kind := range []dnd.NPCOccurrenceKind{
dnd.NPCInteractionKindMentioned, dnd.NPCOccurrenceKindMentioned,
dnd.NPCInteractionKindNoncombatPresence, dnd.NPCOccurrenceKindNoncombatPresence,
dnd.NPCInteractionKindDialogue, dnd.NPCOccurrenceKindDialogue,
dnd.NPCInteractionKindCombatAlly, dnd.NPCOccurrenceKindCombatAlly,
dnd.NPCInteractionKindCombatOpponent, dnd.NPCOccurrenceKindCombatOpponent,
dnd.NPCInteractionKindOther, dnd.NPCOccurrenceKindOther,
} { } {
value := validList() value := validList()
value.Interactions[0].Kind = kind value.Occurrences[0].Kind = kind
if _, err := New().Encode(value); err != nil { if _, err := New().Encode(value); err != nil {
t.Fatalf("Encode(%q) error = %v", kind, err) t.Fatalf("Encode(%q) error = %v", kind, err)
} }
@@ -172,12 +173,12 @@ func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
} }
metadata := codec.Metadata(validList()) metadata := codec.Metadata(validList())
metadata["other"] = true metadata["other"] = true
if next := codec.Metadata(validList()); len(next) != 1 || next["interaction_count"] != 2 { if next := codec.Metadata(validList()); len(next) != 1 || next["occurrence_count"] != 2 {
t.Fatalf("Metadata() = %#v", next) t.Fatalf("Metadata() = %#v", next)
} }
} }
func mutate(value dnd.NPCInteractionList, change func(*dnd.NPCInteractionList)) dnd.NPCInteractionList { func mutate(value dnd.NPCOccurrenceList, change func(*dnd.NPCOccurrenceList)) dnd.NPCOccurrenceList {
change(&value) change(&value)
return value return value
} }

View File

@@ -1,6 +1,7 @@
{ {
"interactions": [ "occurrences": [
{ {
"npc_id": "npc:test-mira",
"name": "Mira Thorn", "name": "Mira Thorn",
"kind": "dialogue", "kind": "dialogue",
"source_refs": [ "source_refs": [
@@ -8,6 +9,7 @@
] ]
}, },
{ {
"npc_id": "npc:test-guard",
"name": "Hooded Guard", "name": "Hooded Guard",
"kind": "combat_opponent", "kind": "combat_opponent",
"source_refs": [ "source_refs": [

View File

@@ -71,7 +71,7 @@ func referenceSlots() []contracts.ReferenceSlot {
Description: "Required NPC-interaction artifact used only as source-free enemy-event grounding.", Description: "Required NPC-interaction artifact used only as source-free enemy-event grounding.",
Required: true, Required: true,
AcceptedMediaTypes: []string{interactioncodec.MediaType}, AcceptedMediaTypes: []string{interactioncodec.MediaType},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCInteractionListKind}, AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCOccurrenceListKind},
MaxBytes: ReferenceMaxBytes, MaxBytes: ReferenceMaxBytes,
}, },
) )
@@ -223,7 +223,7 @@ func prepareNPCInteractionInput(references contracts.ReferenceSet) (*contracts.L
} }
content, err := json.Marshal(struct { content, err := json.Marshal(struct {
Interactions []npcInteractionProjection `json:"npc_interactions"` Interactions []npcInteractionProjection `json:"npc_interactions"`
}{Interactions: projectNPCInteractions(value.Interactions)}) }{Interactions: projectNPCInteractions(value.Occurrences)})
if err != nil { if err != nil {
return nil, fmt.Errorf("encode NPC-interaction grounding: %w", err) return nil, fmt.Errorf("encode NPC-interaction grounding: %w", err)
} }
@@ -272,15 +272,15 @@ func projectCombatTurns(turns []dnd.CombatTurn) []combatTurnProjection {
} }
type npcInteractionProjection struct { type npcInteractionProjection struct {
Name string `json:"name"` Name string `json:"name"`
Kind dnd.NPCInteractionKind `json:"kind"` Kind dnd.NPCOccurrenceKind `json:"kind"`
} }
func projectNPCInteractions(interactions []dnd.NPCInteraction) []npcInteractionProjection { func projectNPCInteractions(occurrences []dnd.NPCOccurrence) []npcInteractionProjection {
projection := make([]npcInteractionProjection, 0, len(interactions)) projection := make([]npcInteractionProjection, 0, len(occurrences))
for _, interaction := range interactions { for _, occurrence := range occurrences {
if interaction.Kind == dnd.NPCInteractionKindCombatOpponent { if occurrence.Kind == dnd.NPCOccurrenceKindCombatOpponent {
projection = append(projection, npcInteractionProjection{Name: interaction.Name, Kind: interaction.Kind}) projection = append(projection, npcInteractionProjection{Name: occurrence.Name, Kind: occurrence.Kind})
} }
} }
return projection return projection

View File

@@ -36,7 +36,7 @@ func TestReferenceSlotsDescribeRequiredTypedArtifacts(t *testing.T) {
{NPCRegistryReferenceSlot, dnd.NPCRegistryKind}, {NPCRegistryReferenceSlot, dnd.NPCRegistryKind},
{SceneDescriptionReferenceSlot, dnd.SceneDescriptionListKind}, {SceneDescriptionReferenceSlot, dnd.SceneDescriptionListKind},
{CombatTurnReferenceSlot, dnd.CombatTurnListKind}, {CombatTurnReferenceSlot, dnd.CombatTurnListKind},
{NPCInteractionReferenceSlot, dnd.NPCInteractionListKind}, {NPCInteractionReferenceSlot, dnd.NPCOccurrenceListKind},
} { } {
slot, ok := byName[want.name] slot, ok := byName[want.name]
if !ok || !slot.Required || slot.MaxBytes != ReferenceMaxBytes || len(slot.AcceptedMediaTypes) != 1 || slot.AcceptedMediaTypes[0] != "application/json" || len(slot.AcceptedArtifactKinds) != 1 || slot.AcceptedArtifactKinds[0] != want.kind { if !ok || !slot.Required || slot.MaxBytes != ReferenceMaxBytes || len(slot.AcceptedMediaTypes) != 1 || slot.AcceptedMediaTypes[0] != "application/json" || len(slot.AcceptedArtifactKinds) != 1 || slot.AcceptedArtifactKinds[0] != want.kind {
@@ -220,9 +220,9 @@ func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) co
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
interactionContent, err := interactioncodec.New().Encode(dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ interactionContent, err := interactioncodec.New().Encode(dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
{Name: enemy, Kind: dnd.NPCInteractionKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}}, {NPCID: identity.DeriveID(enemy), Name: enemy, Kind: dnd.NPCOccurrenceKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}},
{Name: "Aria", Kind: dnd.NPCInteractionKindCombatAlly, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}}, {NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindCombatAlly, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}},
}}) }})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)

View File

@@ -8,8 +8,8 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
type orderedInteractionResponse struct { type orderedOccurrenceResponse struct {
value interactionResponse value occurrenceResponse
earliest int earliest int
hasEvidence bool hasEvidence bool
} }
@@ -18,11 +18,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
if response == nil { if response == nil {
return return
} }
ordered := make([]orderedInteractionResponse, len(response.Interactions)) ordered := make([]orderedOccurrenceResponse, len(response.Occurrences))
for index := range response.Interactions { for index := range response.Occurrences {
earliest, hasEvidence := canonicalizeInteraction(&response.Interactions[index], order, sourceID) earliest, hasEvidence := canonicalizeOccurrence(&response.Occurrences[index], order, sourceID)
ordered[index] = orderedInteractionResponse{ ordered[index] = orderedOccurrenceResponse{
value: response.Interactions[index], value: response.Occurrences[index],
earliest: earliest, earliest: earliest,
hasEvidence: hasEvidence, hasEvidence: hasEvidence,
} }
@@ -37,32 +37,33 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
return ordered[i].earliest < ordered[j].earliest return ordered[i].earliest < ordered[j].earliest
}) })
for index := range ordered { for index := range ordered {
response.Interactions[index] = ordered[index].value response.Occurrences[index] = ordered[index].value
} }
} }
func canonicalizeInteraction(interaction *interactionResponse, order shared.SourceRefOrder, sourceID string) (int, bool) { func canonicalizeOccurrence(occurrence *occurrenceResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
if interaction == nil { if occurrence == nil {
return 0, false return 0, false
} }
refs := order.Canonicalize(canonicalSourceRefs(interaction.SourceRefs, sourceID)) refs := order.Canonicalize(canonicalSourceRefs(occurrence.SourceRefs, sourceID))
interaction.SourceRefs = interactionResponseRefs(refs) occurrence.SourceRefs = occurrenceResponseRefs(refs)
return order.EarliestValid(refs) return order.EarliestValid(refs)
} }
func canonicalInteractionList(response extractionResponse, sourceID string) dnd.NPCInteractionList { func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.NPCOccurrenceList {
interactions := make([]dnd.NPCInteraction, len(response.Interactions)) occurrences := make([]dnd.NPCOccurrence, len(response.Occurrences))
for index, interaction := range response.Interactions { for index, occurrence := range response.Occurrences {
interactions[index] = dnd.NPCInteraction{ occurrences[index] = dnd.NPCOccurrence{
Name: interaction.Name, NPCID: occurrence.NPCID,
Kind: dnd.NPCInteractionKind(interaction.Kind), Name: occurrence.Name,
SourceRefs: canonicalSourceRefs(interaction.SourceRefs, sourceID), Kind: dnd.NPCOccurrenceKind(occurrence.Kind),
SourceRefs: canonicalSourceRefs(occurrence.SourceRefs, sourceID),
} }
} }
if response.Interactions == nil { if response.Occurrences == nil {
interactions = nil occurrences = nil
} }
return dnd.NPCInteractionList{Interactions: interactions} return dnd.NPCOccurrenceList{Occurrences: occurrences}
} }
func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) []source.SourceRef { func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) []source.SourceRef {
@@ -76,7 +77,7 @@ func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) [
return out return out
} }
func interactionResponseRefs(refs []source.SourceRef) []interactionSourceRefResponse { func occurrenceResponseRefs(refs []source.SourceRef) []interactionSourceRefResponse {
if refs == nil { if refs == nil {
return nil return nil
} }

View File

@@ -52,7 +52,7 @@ func referenceSlots() []contracts.ReferenceSlot {
return slots return slots
} }
var _ contracts.Extractor[dnd.NPCInteractionList] = (*Extractor)(nil) var _ contracts.Extractor[dnd.NPCOccurrenceList] = (*Extractor)(nil)
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil) var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
@@ -132,33 +132,33 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "prompt", Value: e.promptSHA}, {Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA}, {Name: "response_schema", Value: e.responseSchemaSHA},
{Name: "mapping_policy", Value: mappingPolicy}, {Name: "mapping_policy", Value: mappingPolicy},
{Name: "npc_registry", Value: seeded.ProjectionDigest()}, {Name: "npc_registry", Value: seeded.IdentityPromptInput().Digest},
} }
} }
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.NPCInteractionList], error) { func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.NPCOccurrenceList], error) {
if e == nil { if e == nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("extractor must not be nil") return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("extractor must not be nil")
} }
if e.llm == nil { if e.llm == nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("LLM client must not be nil") return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("LLM client must not be nil")
} }
sourceInput, err := shared.PrepareChunkExtraction(ctx, req) sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
if err != nil { if err != nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("%w", err) return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("%w", err)
} }
order := shared.NewSourceRefOrder(req.Source) order := shared.NewSourceRefOrder(req.Source)
npcRegistry, err := e.npcResolver.Resolve(req.References) npcRegistry, err := e.npcResolver.Resolve(req.References)
if err != nil { if err != nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("resolve NPC registry: %w", err) return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("resolve NPC registry: %w", err)
} }
if !npcRegistry.Bound() { if !npcRegistry.Bound() {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("NPC registry reference is required") return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("NPC registry reference is required")
} }
var response extractionResponse var response extractionResponse
inputs := shared.PromptInputs(sourceInput, req.References) inputs := shared.PromptInputs(sourceInput, req.References)
inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput() inputs[NPCRegistryReferenceSlot] = npcRegistry.IdentityPromptInput()
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key, StageName: Key,
PromptID: PromptID, PromptID: PromptID,
@@ -167,10 +167,27 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
SessionID: req.SessionID, SessionID: req.SessionID,
Inputs: inputs, Inputs: inputs,
}, &response); err != nil { }, &response); err != nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("complete structured output: %w", err) return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
} }
canonicalizeResponse(&response, order, req.Source.ID) canonicalizeResponse(&response, order, req.Source.ID)
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{Value: canonicalInteractionList(response, req.Source.ID)}, nil value := canonicalOccurrenceList(response, req.Source.ID)
if err := validateRegistryPairs(value, npcRegistry); err != nil {
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("validate NPC registry pairs: %w", err)
}
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{Value: value}, nil
}
func validateRegistryPairs(value dnd.NPCOccurrenceList, registry *npcregistry.Registry) error {
for index, occurrence := range value.Occurrences {
canonical, ok := registry.LookupID(occurrence.NPCID)
if !ok {
return fmt.Errorf("occurrences[%d].npc_id is not in the NPC registry", index)
}
if occurrence.Name != canonical.Name {
return fmt.Errorf("occurrences[%d].name does not match npc_id", index)
}
}
return nil
} }
func ModuleSpec() pipeline.ModuleSpec { func ModuleSpec() pipeline.ModuleSpec {
@@ -180,13 +197,13 @@ func ModuleSpec() pipeline.ModuleSpec {
ExecutionClass: contracts.ExecutionClassLLMBacked, ExecutionClass: contracts.ExecutionClassLLMBacked,
Requires: append([]string(nil), requiredCapabilities...), Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...), Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.NPCInteractionListKind, ArtifactKind: dnd.NPCOccurrenceListKind,
ReferenceSlots: referenceSlots(), ReferenceSlots: referenceSlots(),
} }
} }
func Register(registry *pipeline.ExtractorRegistry) error { func Register(registry *pipeline.ExtractorRegistry) error {
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.NPCInteractionList], error) { return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.NPCOccurrenceList], error) {
options, err := DecodeOptions(request.Options) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -17,14 +17,14 @@ import (
) )
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) { func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{ client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{Name: "Other", Kind: "other", SourceRefs: interactionRefs(30, 30)}, {NPCID: identity.DeriveID("Other"), Name: "Other", Kind: "other", SourceRefs: interactionRefs(30, 30)},
{Name: "Opponent", Kind: "combat_opponent", SourceRefs: interactionRefs(20, 20)}, {NPCID: identity.DeriveID("Opponent"), Name: "Opponent", Kind: "combat_opponent", SourceRefs: interactionRefs(20, 20)},
{Name: "Ally", Kind: "combat_ally", SourceRefs: interactionRefs(5, 5)}, {NPCID: identity.DeriveID("Ally"), Name: "Ally", Kind: "combat_ally", SourceRefs: interactionRefs(5, 5)},
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(interactionRefs(2, 2), interactionRefs(2, 2)...)}, {NPCID: identity.DeriveID("Speaker"), Name: "Speaker", Kind: "dialogue", SourceRefs: append(interactionRefs(2, 2), interactionRefs(2, 2)...)},
{Name: "Present", Kind: "noncombat_presence", SourceRefs: interactionRefs(7, 7)}, {NPCID: identity.DeriveID("Present"), Name: "Present", Kind: "noncombat_presence", SourceRefs: interactionRefs(7, 7)},
{Name: "Mentioned", Kind: "mentioned", SourceRefs: interactionRefs(10, 10)}, {NPCID: identity.DeriveID("Mentioned"), Name: "Mentioned", Kind: "mentioned", SourceRefs: interactionRefs(10, 10)},
{Name: "Invalid", Kind: "unsupported", SourceRefs: interactionRefs(0, 0)}, {NPCID: identity.DeriveID("Invalid"), Name: "Invalid", Kind: "unsupported", SourceRefs: interactionRefs(0, 0)},
}}} }}}
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid") references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
req := extractionRequest() req := extractionRequest()
@@ -37,21 +37,21 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid"}) { if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid"}) {
t.Fatalf("interaction order = %#v", got) t.Fatalf("interaction order = %#v", got)
} }
if got := interactionKinds(result.Value); !reflect.DeepEqual(got, []dnd.NPCInteractionKind{ if got := interactionKinds(result.Value); !reflect.DeepEqual(got, []dnd.NPCOccurrenceKind{
dnd.NPCInteractionKindMentioned, dnd.NPCOccurrenceKindMentioned,
dnd.NPCInteractionKindDialogue, dnd.NPCOccurrenceKindDialogue,
dnd.NPCInteractionKindNoncombatPresence, dnd.NPCOccurrenceKindNoncombatPresence,
dnd.NPCInteractionKindCombatAlly, dnd.NPCOccurrenceKindCombatAlly,
dnd.NPCInteractionKindCombatOpponent, dnd.NPCOccurrenceKindCombatOpponent,
dnd.NPCInteractionKindOther, dnd.NPCOccurrenceKindOther,
"unsupported", "unsupported",
}) { }) {
t.Fatalf("interaction kinds = %#v", got) t.Fatalf("interaction kinds = %#v", got)
} }
if refs := result.Value.Interactions[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) { if refs := result.Value.Occurrences[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
t.Fatalf("canonical source refs = %#v", refs) t.Fatalf("canonical source refs = %#v", refs)
} }
if invalid := result.Value.Interactions[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 != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid) t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid)
} }
if len(client.requests) != 1 { if len(client.requests) != 1 {
@@ -60,15 +60,15 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
} }
func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) { func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{ client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{Name: "Later", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)}, {NPCID: identity.DeriveID("Later"), Name: "Later", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)},
{Name: "First", Kind: "mentioned", SourceRefs: []interactionSourceRefResponse{ {NPCID: identity.DeriveID("First"), Name: "First", Kind: "mentioned", SourceRefs: []interactionSourceRefResponse{
{StartUnitID: 10, EndUnitID: 10}, {StartUnitID: 10, EndUnitID: 10},
{StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 999, EndUnitID: 0}, {StartUnitID: 999, EndUnitID: 0},
}}, }},
{Name: "Second", Kind: "other", SourceRefs: interactionRefs(30, 30)}, {NPCID: identity.DeriveID("Second"), Name: "Second", Kind: "other", SourceRefs: interactionRefs(30, 30)},
}}} }}}
references := requiredRegistryReferences(t, "Later", "First", "Second") references := requiredRegistryReferences(t, "Later", "First", "Second")
req := extractionRequest() req := extractionRequest()
@@ -84,12 +84,12 @@ func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) {
if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) { if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) {
t.Fatalf("interaction order = %#v, want document chronology with stable equal-evidence ties", got) t.Fatalf("interaction order = %#v, want document chronology with stable equal-evidence ties", got)
} }
refs := result.Value.Interactions[0].SourceRefs refs := result.Value.Occurrences[0].SourceRefs
if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) { if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs) t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
} }
refs[0].StartUnitID = 777 refs[0].StartUnitID = 777
for _, interaction := range client.response.Interactions { for _, interaction := range client.response.Occurrences {
for _, ref := range interaction.SourceRefs { for _, ref := range interaction.SourceRefs {
if ref.StartUnitID == 777 { if ref.StartUnitID == 777 {
t.Fatal("result source references alias the model response") t.Fatal("result source references alias the model response")
@@ -107,9 +107,9 @@ func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
} }
} }
func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) { func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{{ client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10), NPCID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10),
}}}} }}}}
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard") references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
req := extractionRequest() req := extractionRequest()
@@ -119,10 +119,10 @@ func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T)
} }
request := client.requests[0] request := client.requests[0]
registry := request.Inputs[NPCRegistryReferenceSlot] registry := request.Inputs[NPCRegistryReferenceSlot]
if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` { if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"},{"id":"`+identity.DeriveID("Hooded Guard")+`","name":"Hooded Guard"}]}` {
t.Fatalf("registry prompt input = %#v, want exact names-only projection", registry) t.Fatalf("registry prompt input = %#v, want exact ID and name projection", registry)
} }
for _, forbidden := range []string{"npc:sha256:", "other-session", "start_unit_id"} { for _, forbidden := range []string{"other-session", "start_unit_id"} {
if strings.Contains(string(registry.Content), forbidden) { if strings.Contains(string(registry.Content), forbidden) {
t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content) t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content)
} }
@@ -162,8 +162,29 @@ func TestExtractRequiresBoundRegistryBeforeLLMCall(t *testing.T) {
} }
} }
func TestExtractRejectsUnknownIDsAndMismatchedNames(t *testing.T) {
references := requiredRegistryReferences(t, "Mira Thorn")
for _, test := range []struct {
name string
occurrence occurrenceResponse
want string
}{
{"unknown ID", occurrenceResponse{NPCID: "npc:unknown", Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)}, "npc_id is not in the NPC registry"},
{"mismatched name", occurrenceResponse{NPCID: identity.DeriveID("Mira Thorn"), Name: "Hooded Guard", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)}, "name does not match npc_id"},
} {
t.Run(test.name, func(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{test.occurrence}}}
req := extractionRequest()
req.References = references
if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Extract() error = %v, want %q", err, test.want)
}
})
}
}
func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) { func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{}}} client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{}}}
references := requiredRegistryReferences(t, "Mira Thorn") references := requiredRegistryReferences(t, "Mira Thorn")
req := extractionRequest() req := extractionRequest()
req.References = references req.References = references
@@ -171,7 +192,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
if _, err := extractor.Extract(context.Background(), req); err != nil { if _, err := extractor.Extract(context.Background(), req); err != nil {
t.Fatalf("Extract() error = %v", err) t.Fatalf("Extract() error = %v", err)
} }
if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" { if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` || input.OriginURI != "" {
t.Fatalf("generated registry prompt input = %#v", input) t.Fatalf("generated registry prompt input = %#v", input)
} }
metadata := extractor.ManifestMetadata() metadata := extractor.ManifestMetadata()
@@ -191,11 +212,11 @@ func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) {
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}}, Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
}, },
}} }}
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{}}} client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{}}}
req := extractionRequest() req := extractionRequest()
req.References = references req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req) result, err := newExtractor(t, client, references).Extract(context.Background(), req)
if err != nil || result.Value.Interactions == nil || len(result.Value.Interactions) != 0 { if err != nil || result.Value.Occurrences == nil || len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() = %#v, %v; want present empty interactions", result, err) t.Fatalf("Extract() = %#v, %v; want present empty interactions", result, err)
} }
} }
@@ -230,7 +251,7 @@ func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
func TestModuleSpecRegistrationMetadataAndFingerprints(t *testing.T) { func TestModuleSpecRegistrationMetadataAndFingerprints(t *testing.T) {
got := ModuleSpec() got := ModuleSpec()
if got.Key != Key || got.Stage != pipeline.StageExtract || got.ArtifactKind != dnd.NPCInteractionListKind || !reflect.DeepEqual(got.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(got.Provides, []string{"dnd.npc_interactions"}) { if got.Key != Key || got.Stage != pipeline.StageExtract || got.ArtifactKind != dnd.NPCOccurrenceListKind || !reflect.DeepEqual(got.Requires, []string{"chunks", "source.transcript"}) || !reflect.DeepEqual(got.Provides, []string{"dnd.npc_interactions"}) {
t.Fatalf("ModuleSpec() = %#v", got) t.Fatalf("ModuleSpec() = %#v", got)
} }
var registrySlot contracts.ReferenceSlot var registrySlot contracts.ReferenceSlot
@@ -342,17 +363,17 @@ func interactionRefs(start, end int) []interactionSourceRefResponse {
return []interactionSourceRefResponse{{StartUnitID: start, EndUnitID: end}} return []interactionSourceRefResponse{{StartUnitID: start, EndUnitID: end}}
} }
func interactionNames(value dnd.NPCInteractionList) []string { func interactionNames(value dnd.NPCOccurrenceList) []string {
names := make([]string, len(value.Interactions)) names := make([]string, len(value.Occurrences))
for index, interaction := range value.Interactions { for index, interaction := range value.Occurrences {
names[index] = interaction.Name names[index] = interaction.Name
} }
return names return names
} }
func interactionKinds(value dnd.NPCInteractionList) []dnd.NPCInteractionKind { func interactionKinds(value dnd.NPCOccurrenceList) []dnd.NPCOccurrenceKind {
kinds := make([]dnd.NPCInteractionKind, len(value.Interactions)) kinds := make([]dnd.NPCOccurrenceKind, len(value.Occurrences))
for index, interaction := range value.Interactions { for index, interaction := range value.Occurrences {
kinds[index] = interaction.Kind kinds[index] = interaction.Kind
} }
return kinds return kinds

View File

@@ -1,10 +1,11 @@
package npcinteractions package npcinteractions
type extractionResponse struct { type extractionResponse struct {
Interactions []interactionResponse `json:"interactions"` Occurrences []occurrenceResponse `json:"occurrences"`
} }
type interactionResponse struct { type occurrenceResponse struct {
NPCID string `json:"npc_id"`
Name string `json:"name"` Name string `json:"name"`
Kind string `json:"kind"` Kind string `json:"kind"`
SourceRefs []interactionSourceRefResponse `json:"source_refs"` SourceRefs []interactionSourceRefResponse `json:"source_refs"`

View File

@@ -6,13 +6,13 @@ import (
) )
func TestExtractionResponsePreservesValidatorOwnedSemantics(t *testing.T) { func TestExtractionResponsePreservesValidatorOwnedSemantics(t *testing.T) {
content := []byte(`{"interactions":[{"name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`) content := []byte(`{"occurrences":[{"npc_id":"","name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
var response extractionResponse var response extractionResponse
if err := json.Unmarshal(content, &response); err != nil { if err := json.Unmarshal(content, &response); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err) t.Fatalf("json.Unmarshal() error = %v", err)
} }
interaction := response.Interactions[0] occurrence := response.Occurrences[0]
if interaction.Name != "" || interaction.Kind != "unsupported" || interaction.SourceRefs[0] != (interactionSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) { if occurrence.NPCID != "" || occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (interactionSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
t.Fatalf("decoded response = %#v", interaction) t.Fatalf("decoded response = %#v", occurrence)
} }
} }

View File

@@ -20,7 +20,7 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := fs.ReadFile(schemaFS, "dnd_npc_interactions_llm.v1.json"); err != nil { if _, err := fs.ReadFile(schemaFS, "dnd_npc_occurrences_llm.v1.json"); err != nil {
t.Fatalf("response schema asset: %v", err) t.Fatalf("response schema asset: %v", err)
} }
options, err := registry.PromptKitOptions() options, err := registry.PromptKitOptions()
@@ -42,13 +42,13 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
"players": promptkit.Inline("interaction-player"), "players": promptkit.Inline("interaction-player"),
"party": promptkit.Inline("Mira: ranger"), "party": promptkit.Inline("Mira: ranger"),
"glossary": promptkit.Inline("Greencloak: title"), "glossary": promptkit.Inline("Greencloak: title"),
"npc_registry": promptkit.Inline(`{"npcs":[{"name":"interaction-npc"}]}`), "npc_registry": promptkit.Inline(`{"npcs":[{"id":"npc:sha256:test","name":"interaction-npc"}]}`),
}, },
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_interactions_llm.v1.json" { if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_occurrences_llm.v1.json" {
t.Fatalf("prepared prompt = %#v", prepared) t.Fatalf("prepared prompt = %#v", prepared)
} }
} }
@@ -59,7 +59,7 @@ func TestPromptMetadataDoesNotExposeAssetContent(t *testing.T) {
t.Fatalf("promptAssetMetadata() = %q, %v", hash, err) t.Fatalf("promptAssetMetadata() = %q, %v", hash, err)
} }
metadata := newExtractor(t, &fakeInteractionsLLMClient{}).ManifestMetadata() metadata := newExtractor(t, &fakeInteractionsLLMClient{}).ManifestMetadata()
for _, forbidden := range []string{"common-dnd-system", "dnd_npc_interactions_llm.v1.json"} { for _, forbidden := range []string{"common-dnd-system", "dnd_npc_occurrences_llm.v1.json"} {
if strings.Contains(strings.Join(mapValues(metadata), " "), forbidden) { if strings.Contains(strings.Join(mapValues(metadata), " "), forbidden) {
t.Fatalf("metadata leaked prompt or schema content %q: %#v", forbidden, metadata) t.Fatalf("metadata leaked prompt or schema content %q: %#v", forbidden, metadata)
} }

View File

@@ -4,9 +4,9 @@ import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
const ( const (
PromptID = "dnd.npc_interactions" PromptID = "dnd.npc_interactions"
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_npc_interactions_llm") ResponseSchemaKey = llm.ResponseSchemaKey("dnd_npc_occurrences_llm")
ResponseSchemaID = "notarius.dnd.npc_interactions.llm" ResponseSchemaID = "notarius.dnd.npc_occurrences.llm"
ResponseSchemaName = "notarius_dnd_npc_interactions_llm_v1" ResponseSchemaName = "notarius_dnd_npc_occurrences_llm_v1"
SchemaVersion = "v1" SchemaVersion = "v1"
) )
@@ -20,6 +20,6 @@ func loadResponseSchema() (llm.ResponseSchema, error) {
ID: ResponseSchemaID, ID: ResponseSchemaID,
Version: SchemaVersion, Version: SchemaVersion,
Name: ResponseSchemaName, Name: ResponseSchemaName,
AssetPath: "schemas/dnd_npc_interactions_llm.v1.json", AssetPath: "schemas/dnd_npc_occurrences_llm.v1.json",
}) })
} }

View File

@@ -27,7 +27,7 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
} }
semanticCandidate := validInteractionResponse() semanticCandidate := validInteractionResponse()
interaction := semanticCandidate["interactions"].([]any)[0].(map[string]any) interaction := semanticCandidate["occurrences"].([]any)[0].(map[string]any)
interaction["name"] = "" interaction["name"] = ""
interaction["kind"] = "unsupported" interaction["kind"] = "unsupported"
ref := interaction["source_refs"].([]any)[0].(map[string]any) ref := interaction["source_refs"].([]any)[0].(map[string]any)
@@ -42,6 +42,7 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
} }
for _, mutate := range []func(map[string]any){ for _, mutate := range []func(map[string]any){
func(record map[string]any) { delete(record, "npc_id") },
func(record map[string]any) { delete(record, "name") }, func(record map[string]any) { delete(record, "name") },
func(record map[string]any) { record["kind"] = 1 }, func(record map[string]any) { record["kind"] = 1 },
func(record map[string]any) { record["unexpected"] = true }, func(record map[string]any) { record["unexpected"] = true },
@@ -50,7 +51,7 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
}, },
} { } {
candidate := validInteractionResponse() candidate := validInteractionResponse()
mutate(candidate["interactions"].([]any)[0].(map[string]any)) mutate(candidate["occurrences"].([]any)[0].(map[string]any))
content, err := json.Marshal(candidate) content, err := json.Marshal(candidate)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -79,8 +80,8 @@ func TestResponseSchemaIsDefensiveAndContentSafe(t *testing.T) {
} }
func validInteractionResponse() map[string]any { func validInteractionResponse() map[string]any {
return map[string]any{"interactions": []any{map[string]any{ return map[string]any{"occurrences": []any{map[string]any{
"name": "Mira Thorn", "kind": "dialogue", "npc_id": "npc:sha256:test", "name": "Mira Thorn", "kind": "dialogue",
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
}}} }}}
} }

View File

@@ -11,7 +11,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions" interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
@@ -22,7 +21,6 @@ const (
normalizationPolicy = "dnd.npc_interactions.normalize.v2" normalizationPolicy = "dnd.npc_interactions.normalize.v2"
NormalizationPolicy = normalizationPolicy NormalizationPolicy = normalizationPolicy
ReasonCodeNameCanonicalized = "npc_interaction_name_canonicalized"
ReasonCodeSourceRefsNormalized = "source_references_normalized" ReasonCodeSourceRefsNormalized = "source_references_normalized"
ReasonCodeInteractionsReordered = "npc_interactions_reordered" ReasonCodeInteractionsReordered = "npc_interactions_reordered"
ReasonCodeDuplicateCollapsed = "duplicate_npc_interaction_collapsed" ReasonCodeDuplicateCollapsed = "duplicate_npc_interaction_collapsed"
@@ -44,7 +42,7 @@ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
Roster: "Deprecated alias for party roster reference material used only for interaction disambiguation.", Roster: "Deprecated alias for party roster reference material used only for interaction disambiguation.",
} }
var _ contracts.Normalizer[dnd.NPCInteractionList] = (*Normalizer)(nil) var _ contracts.Normalizer[dnd.NPCOccurrenceList] = (*Normalizer)(nil)
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil) var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
@@ -79,7 +77,6 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
} }
metadata := map[string]any{ metadata := map[string]any{
"normalization_policy": normalizationPolicy, "normalization_policy": normalizationPolicy,
"identity_policy": identity.Policy,
} }
seeded := n.npcResolver.Seeded() seeded := n.npcResolver.Seeded()
if seeded.Bound() { if seeded.Bound() {
@@ -95,82 +92,74 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
} }
return []pipeline.CheckpointFingerprint{ return []pipeline.CheckpointFingerprint{
{Name: "normalization_policy", Value: normalizationPolicy}, {Name: "normalization_policy", Value: normalizationPolicy},
{Name: "identity_policy", Value: identity.Policy}, {Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityPromptInput().Digest},
{Name: "npc_registry", Value: n.npcResolver.Seeded().ProjectionDigest()},
} }
} }
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCInteractionList]) (contracts.TypedNormalizeResult[dnd.NPCInteractionList], error) { func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]) (contracts.TypedNormalizeResult[dnd.NPCOccurrenceList], error) {
if n == nil || n.npcResolver == nil { if n == nil || n.npcResolver == nil {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("normalizer must not be nil") return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("normalizer must not be nil")
} }
if ctx == nil { if ctx == nil {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("context must not be nil") return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("context must not be nil")
} }
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("context error before normalize: %w", err) return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("context error before normalize: %w", err)
} }
registry, err := n.npcResolver.Resolve(req.References) registry, err := n.npcResolver.Resolve(req.References)
if err != nil { if err != nil {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("resolve NPC registry: %w", err) return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("resolve NPC registry: %w", err)
} }
if !registry.Bound() { if !registry.Bound() {
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required") return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("NPC registry reference is required")
} }
index := source.NewDocumentIndex(req.Source) index := source.NewDocumentIndex(req.Source)
order := shared.NewSourceRefOrderFromIndex(index) order := shared.NewSourceRefOrderFromIndex(index)
value, warnings := normalizeList(req.MergeOutput.Value, index, order, registry) value, warnings, err := normalizeList(req.MergeOutput.Value, index, order, registry)
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil if err != nil {
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("validate NPC registry pairs: %w", err)
}
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{Value: value, Warnings: warnings}, nil
} }
type normalizedRecord struct { type normalizedRecord struct {
interaction dnd.NPCInteraction occurrence dnd.NPCOccurrence
inputIndex int inputIndex int
} }
type nameCanonicalization struct { func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrenceList, []contracts.Warning, error) {
from string if input.Occurrences == nil {
to string return dnd.NPCOccurrenceList{}, nil, nil
}
func normalizeList(input dnd.NPCInteractionList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteractionList, []contracts.Warning) {
if input.Interactions == nil {
return dnd.NPCInteractionList{}, nil
} }
records := make([]normalizedRecord, len(input.Interactions)) records := make([]normalizedRecord, len(input.Occurrences))
warnings := make([]contracts.Warning, 0) warnings := make([]contracts.Warning, 0)
for index, inputInteraction := range input.Interactions { for index, inputOccurrence := range input.Occurrences {
interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, order, registry) occurrence, refsChanged, err := normalizeOccurrence(inputOccurrence, order, registry)
records[index] = normalizedRecord{interaction: interaction, inputIndex: index} if err != nil {
if nameChange != nil { return dnd.NPCOccurrenceList{}, nil, fmt.Errorf("occurrences[%d]: %w", index, err)
warnings = append(warnings, contracts.Warning{
Scope: interactionScope(index),
ReasonCode: ReasonCodeNameCanonicalized,
Message: fmt.Sprintf("input index %d: NPC name canonicalized from %s to %s",
index, diagnostics.Quote(nameChange.from), diagnostics.Quote(nameChange.to)),
})
} }
records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index}
if refsChanged { if refsChanged {
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: interactionScope(index), Scope: occurrenceScope(index),
ReasonCode: ReasonCodeSourceRefsNormalized, ReasonCode: ReasonCodeSourceRefsNormalized,
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
index, len(inputInteraction.SourceRefs), len(interaction.SourceRefs)), index, len(inputOccurrence.SourceRefs), len(occurrence.SourceRefs)),
}) })
} }
} }
sort.SliceStable(records, func(left, right int) bool { sort.SliceStable(records, func(left, right int) bool {
return interactionmodel.Less(order, records[left].interaction, records[right].interaction) return interactionmodel.Less(order, records[left].occurrence, records[right].occurrence)
}) })
for position, record := range records { for position, record := range records {
if position == record.inputIndex { if position == record.inputIndex {
continue continue
} }
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: interactionScope(record.inputIndex), Scope: occurrenceScope(record.inputIndex),
ReasonCode: ReasonCodeInteractionsReordered, ReasonCode: ReasonCodeInteractionsReordered,
Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position), Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position),
}) })
@@ -178,24 +167,24 @@ func normalizeList(input dnd.NPCInteractionList, documentIndex source.DocumentIn
output, duplicateWarnings := collapseDuplicates(records, documentIndex) output, duplicateWarnings := collapseDuplicates(records, documentIndex)
warnings = append(warnings, duplicateWarnings...) warnings = append(warnings, duplicateWarnings...)
return dnd.NPCInteractionList{Interactions: output}, return dnd.NPCOccurrenceList{Occurrences: output},
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted) diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted), nil
} }
func normalizeInteraction(input dnd.NPCInteraction, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) { func normalizeOccurrence(input dnd.NPCOccurrence, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrence, bool, error) {
output := cloneInteraction(input) canonical, ok := registry.LookupID(input.NPCID)
if canonical, ok := registry.Lookup(identity.NormalizeDisplay(input.Name)); ok { if !ok {
output.Name = canonical.Name return dnd.NPCOccurrence{}, false, fmt.Errorf("npc_id is not in the NPC registry")
} }
var nameChange *nameCanonicalization if input.Name != canonical.Name {
if input.Name != output.Name { return dnd.NPCOccurrence{}, false, fmt.Errorf("name does not match npc_id")
nameChange = &nameCanonicalization{from: input.Name, to: output.Name}
} }
output := cloneOccurrence(input)
output.SourceRefs = order.Canonicalize(input.SourceRefs) output.SourceRefs = order.Canonicalize(input.SourceRefs)
return output, nameChange, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs) return output, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs), nil
} }
func cloneInteraction(input dnd.NPCInteraction) dnd.NPCInteraction { func cloneOccurrence(input dnd.NPCOccurrence) dnd.NPCOccurrence {
output := input output := input
if input.SourceRefs != nil { if input.SourceRefs != nil {
output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...) output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...)
@@ -208,19 +197,19 @@ type duplicateGroup struct {
removed []int removed []int
} }
func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCInteraction, []contracts.Warning) { func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCOccurrence, []contracts.Warning) {
if len(records) == 0 { if len(records) == 0 {
return make([]dnd.NPCInteraction, 0), nil return make([]dnd.NPCOccurrence, 0), nil
} }
keep := make([]bool, len(records)) keep := make([]bool, len(records))
groups := make([]duplicateGroup, 0) groups := make([]duplicateGroup, 0)
groupByKey := make(map[string]int) groupByKey := make(map[string]int)
for index, record := range records { for index, record := range records {
if !interactionmodel.ValidSourceRefs(documentIndex, record.interaction.SourceRefs) { if !interactionmodel.ValidSourceRefs(documentIndex, record.occurrence.SourceRefs) {
keep[index] = true keep[index] = true
continue continue
} }
key := interactionmodel.ExactIdentity(record.interaction) key := interactionmodel.ExactIdentity(record.occurrence)
groupIndex, exists := groupByKey[key] groupIndex, exists := groupByKey[key]
if !exists { if !exists {
groupByKey[key] = len(groups) groupByKey[key] = len(groups)
@@ -230,10 +219,10 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen
} }
groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex) groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex)
} }
output := make([]dnd.NPCInteraction, 0, len(records)) output := make([]dnd.NPCOccurrence, 0, len(records))
for index, record := range records { for index, record := range records {
if keep[index] { if keep[index] {
output = append(output, cloneInteraction(record.interaction)) output = append(output, cloneOccurrence(record.occurrence))
} }
} }
warnings := make([]contracts.Warning, 0) warnings := make([]contracts.Warning, 0)
@@ -251,14 +240,14 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
issues[index] = fmt.Sprintf("removed input index %d", removedIndex) issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
} }
return contracts.Warning{ return contracts.Warning{
Scope: interactionScope(retainedIndex), Scope: occurrenceScope(retainedIndex),
ReasonCode: ReasonCodeDuplicateCollapsed, ReasonCode: ReasonCodeDuplicateCollapsed,
Message: diagnostics.Aggregate( Message: diagnostics.Aggregate(
fmt.Sprintf("duplicate NPC interaction collapsed; retained input index %d", retainedIndex), issues), fmt.Sprintf("duplicate NPC interaction collapsed; retained input index %d", retainedIndex), issues),
} }
} }
func interactionScope(index int) string { return fmt.Sprintf("interactions[%d]", index) } func occurrenceScope(index int) string { return fmt.Sprintf("occurrences[%d]", index) }
func referenceSlots() []contracts.ReferenceSlot { func referenceSlots() []contracts.ReferenceSlot {
slots := shared.ReferenceSlots(referenceSlotDescriptions) slots := shared.ReferenceSlots(referenceSlotDescriptions)
@@ -281,13 +270,13 @@ func ModuleSpec() pipeline.ModuleSpec {
ExecutionClass: contracts.ExecutionClassDeterministic, ExecutionClass: contracts.ExecutionClassDeterministic,
Requires: append([]string(nil), requiredCapabilities...), Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...), Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.NPCInteractionListKind, ArtifactKind: dnd.NPCOccurrenceListKind,
ReferenceSlots: referenceSlots(), ReferenceSlots: referenceSlots(),
} }
} }
func Register(registry *pipeline.NormalizerRegistry) error { func Register(registry *pipeline.NormalizerRegistry) error {
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCInteractionList], error) { return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.NPCOccurrenceList], error) {
options, err := DecodeOptions(request.Options) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -14,37 +14,37 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
) )
func TestNormalizeCanonicalizesAndClones(t *testing.T) { func TestNormalizeValidatesPairsAndClones(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}} doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
normalizer, err := New(Options{}, npcReferences(t)) normalizer, err := New(Options{}, npcReferences(t))
if err != nil { if err != nil {
t.Fatalf("New() error = %v", err) t.Fatalf("New() error = %v", err)
} }
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
Name: " áRIA ", Kind: dnd.NPCInteractionKindDialogue, NPCID: identity.DeriveID("Ária"), Name: "Ária", Kind: dnd.NPCOccurrenceKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
}}} }}}
original := append([]source.SourceRef(nil), input.Interactions[0].SourceRefs...) original := append([]source.SourceRef(nil), input.Occurrences[0].SourceRefs...)
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}}) result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}})
if err != nil { if err != nil {
t.Fatalf("Normalize() error = %v", err) t.Fatalf("Normalize() error = %v", err)
} }
got := result.Value.Interactions[0] got := result.Value.Occurrences[0]
if got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) { if got.NPCID != identity.DeriveID("Ária") || got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) {
t.Fatalf("normalized interaction = %#v", got) t.Fatalf("normalized interaction = %#v", got)
} }
if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) { if !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
t.Fatalf("warnings = %#v", result.Warnings) t.Fatalf("warnings = %#v", result.Warnings)
} }
if !reflect.DeepEqual(input.Interactions[0].SourceRefs, original) { if !reflect.DeepEqual(input.Occurrences[0].SourceRefs, original) {
t.Fatalf("Normalize() mutated input: %#v", input) t.Fatalf("Normalize() mutated input: %#v", input)
} }
second, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: result.Value}}) second, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: result.Value}})
if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Warnings) != 0 { if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Warnings) != 0 {
t.Fatalf("second normalization = %#v, %v; want idempotent output without warnings", second, err) t.Fatalf("second normalization = %#v, %v; want idempotent output without warnings", second, err)
} }
result.Value.Interactions[0].SourceRefs[0].StartUnitID = 999 result.Value.Occurrences[0].SourceRefs[0].StartUnitID = 999
if input.Interactions[0].SourceRefs[0].StartUnitID == 999 { if input.Occurrences[0].SourceRefs[0].StartUnitID == 999 {
t.Fatal("normalized source refs share input storage") t.Fatal("normalized source refs share input storage")
} }
} }
@@ -54,14 +54,14 @@ func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *te
if err != nil { if err != nil {
t.Fatalf("New() error = %v", err) t.Fatalf("New() error = %v", err)
} }
if _, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{}); err == nil { if _, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{}); err == nil {
t.Fatal("Normalize() accepted an unbound NPC registry") t.Fatal("Normalize() accepted an unbound NPC registry")
} }
for _, input := range []dnd.NPCInteractionList{{}, {Interactions: []dnd.NPCInteraction{}}} { for _, input := range []dnd.NPCOccurrenceList{{}, {Occurrences: []dnd.NPCOccurrence{}}} {
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{ result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{
MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}, References: npcReferences(t), MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}, References: npcReferences(t),
}) })
if err != nil || (result.Value.Interactions == nil) != (input.Interactions == nil) { if err != nil || (result.Value.Occurrences == nil) != (input.Occurrences == nil) {
t.Fatalf("Normalize() = %#v, %v for input %#v", result, err, input) t.Fatalf("Normalize() = %#v, %v for input %#v", result, err, input)
} }
} }
@@ -70,16 +70,20 @@ func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *te
} }
} }
func TestNormalizeLeavesUnrecognizedNamesUntouched(t *testing.T) { func TestNormalizeRejectsUnknownIDsAndMismatchedNames(t *testing.T) {
doc := testDocument() doc := testDocument()
normalizer, err := New(Options{}, npcReferences(t)) normalizer, err := New(Options{}, npcReferences(t))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{interaction(" Unknown NPC ", dnd.NPCInteractionKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})}} for _, occurrence := range []dnd.NPCOccurrence{
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}}) interaction("Unknown NPC", dnd.NPCOccurrenceKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}),
if err != nil || result.Value.Interactions[0].Name != input.Interactions[0].Name || hasWarning(result.Warnings, ReasonCodeNameCanonicalized) { {NPCID: identity.DeriveID("Ária"), Name: "Borin", Kind: dnd.NPCOccurrenceKindOther, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
t.Fatalf("Normalize() = %#v, %v; want untouched unrecognized name", result, err) } {
input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{occurrence}}
if result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}}); err == nil {
t.Fatalf("Normalize() = %#v, %v; want registry pair rejection", result, err)
}
} }
} }
@@ -88,28 +92,28 @@ func TestNormalizeOrdersAndCollapsesExactDuplicatesOnly(t *testing.T) {
ref := func(unit int) source.SourceRef { ref := func(unit int) source.SourceRef {
return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit} return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit}
} }
first := interaction("Ária", dnd.NPCInteractionKindDialogue, ref(50)) first := interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(50))
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
interaction("Borin", dnd.NPCInteractionKindMentioned, ref(90)), interaction("Borin", dnd.NPCOccurrenceKindMentioned, ref(90)),
first, first,
first, first,
interaction("Ária", dnd.NPCInteractionKindCombatAlly, ref(50)), interaction("Ária", dnd.NPCOccurrenceKindCombatAlly, ref(50)),
interaction("Ária", dnd.NPCInteractionKindDialogue, ref(10)), interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(10)),
interaction("Ária", dnd.NPCInteractionKindDialogue, ref(999)), interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(999)),
}} }}
normalizer, err := New(Options{}, npcReferences(t)) normalizer, err := New(Options{}, npcReferences(t))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}}) result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
got := result.Value.Interactions got := result.Value.Occurrences
if len(got) != 5 { if len(got) != 5 {
t.Fatalf("interaction count = %d, want 5: %#v", len(got), got) t.Fatalf("interaction count = %d, want 5: %#v", len(got), got)
} }
if got[0].Kind != dnd.NPCInteractionKindCombatAlly || got[0].SourceRefs[0].StartUnitID != 50 || got[1].SourceRefs[0].StartUnitID != 50 || got[2].SourceRefs[0].StartUnitID != 10 || got[3].SourceRefs[0].StartUnitID != 90 || got[4].SourceRefs[0].StartUnitID != 999 { if got[0].Kind != dnd.NPCOccurrenceKindCombatAlly || got[0].SourceRefs[0].StartUnitID != 50 || got[1].SourceRefs[0].StartUnitID != 50 || got[2].SourceRefs[0].StartUnitID != 10 || got[3].SourceRefs[0].StartUnitID != 90 || got[4].SourceRefs[0].StartUnitID != 999 {
t.Fatalf("canonical order = %#v", got) t.Fatalf("canonical order = %#v", got)
} }
if !hasWarning(result.Warnings, ReasonCodeInteractionsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) { if !hasWarning(result.Warnings, ReasonCodeInteractionsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) {
@@ -122,13 +126,13 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.NPCInteractionListKind || len(spec.ReferenceSlots) == 0 { if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.NPCOccurrenceListKind || len(spec.ReferenceSlots) == 0 {
t.Fatalf("ModuleSpec() = %#v", spec) t.Fatalf("ModuleSpec() = %#v", spec)
} }
if metadata := normalizer.ManifestMetadata(); metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy || metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 { if metadata := normalizer.ManifestMetadata(); metadata["normalization_policy"] != normalizationPolicy || metadata["npc_registry_digest"] == "" || metadata["npc_count"] != 2 {
t.Fatalf("metadata = %#v", metadata) t.Fatalf("metadata = %#v", metadata)
} }
if fingerprints := normalizer.CheckpointFingerprints(); len(fingerprints) != 3 || fingerprints[2].Name != "npc_registry" || fingerprints[2].Value == "" { if fingerprints := normalizer.CheckpointFingerprints(); len(fingerprints) != 2 || fingerprints[1].Name != "npc_registry" || fingerprints[1].Value == "" {
t.Fatalf("fingerprints = %#v", fingerprints) t.Fatalf("fingerprints = %#v", fingerprints)
} }
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil { if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
@@ -139,13 +143,13 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) {
func TestNormalizeBoundsWarnings(t *testing.T) { func TestNormalizeBoundsWarnings(t *testing.T) {
count := diagnostics.MaxWarnings + 5 count := diagnostics.MaxWarnings + 5
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)} doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
input := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, count)} input := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, count)}
for index := range doc.Units { for index := range doc.Units {
doc.Units[index].ID = index + 1 doc.Units[index].ID = index + 1
unitID := count - index unitID := count - index
input.Interactions[index] = interaction( input.Occurrences[index] = interaction(
"Ária", "Ária",
dnd.NPCInteractionKindDialogue, dnd.NPCOccurrenceKindDialogue,
source.SourceRef{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID}, source.SourceRef{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID},
) )
} }
@@ -153,8 +157,8 @@ func TestNormalizeBoundsWarnings(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{ result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{
Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}, Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input},
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -165,8 +169,8 @@ func TestNormalizeBoundsWarnings(t *testing.T) {
} }
} }
func interaction(name string, kind dnd.NPCInteractionKind, ref source.SourceRef) dnd.NPCInteraction { func interaction(name string, kind dnd.NPCOccurrenceKind, ref source.SourceRef) dnd.NPCOccurrence {
return dnd.NPCInteraction{Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}} return dnd.NPCOccurrence{NPCID: identity.DeriveID(name), Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}}
} }
func testDocument() *source.SourceDocument { func testDocument() *source.SourceDocument {

View File

@@ -8,7 +8,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
@@ -26,8 +25,8 @@ func SourceRefsEqual(left, right []source.SourceRef) bool {
return true return true
} }
// Less defines the canonical order for NPC interaction occurrences. // Less defines the canonical order for NPC occurrences.
func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool { func Less(order shared.SourceRefOrder, left, right dnd.NPCOccurrence) bool {
leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs) leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs) rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
if leftHasEvidence != rightHasEvidence { if leftHasEvidence != rightHasEvidence {
@@ -36,8 +35,8 @@ func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
if leftHasEvidence && leftPosition != rightPosition { if leftHasEvidence && leftPosition != rightPosition {
return leftPosition < rightPosition return leftPosition < rightPosition
} }
leftKey := identity.ComparisonKey(left.Name) leftKey := left.NPCID
rightKey := identity.ComparisonKey(right.Name) rightKey := right.NPCID
if leftKey != rightKey { if leftKey != rightKey {
return leftKey < rightKey return leftKey < rightKey
} }
@@ -50,7 +49,7 @@ func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
return sourceRefsLess(order, left.SourceRefs, right.SourceRefs) return sourceRefsLess(order, left.SourceRefs, right.SourceRefs)
} }
// ValidSourceRefs reports whether an interaction has non-empty, valid // ValidSourceRefs reports whether an occurrence has non-empty, valid
// current-document evidence. // current-document evidence.
func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool { func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
if len(refs) == 0 { if len(refs) == 0 {
@@ -64,13 +63,14 @@ func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
return true return true
} }
// ExactIdentity returns a collision-safe key over every durable interaction // ExactIdentity returns a collision-safe key over every durable occurrence
// field. Callers decide whether the record is eligible for duplicate handling. // field. Callers decide whether the record is eligible for duplicate handling.
func ExactIdentity(interaction dnd.NPCInteraction) string { func ExactIdentity(occurrence dnd.NPCOccurrence) string {
var key strings.Builder var key strings.Builder
writeKeyString(&key, interaction.Name) writeKeyString(&key, occurrence.NPCID)
writeKeyString(&key, string(interaction.Kind)) writeKeyString(&key, occurrence.Name)
for _, ref := range interaction.SourceRefs { writeKeyString(&key, string(occurrence.Kind))
for _, ref := range occurrence.SourceRefs {
writeKeyString(&key, ref.SourceID) writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID) writeKeyInt(&key, ref.StartUnitID)
writeKeyInt(&key, ref.EndUnitID) writeKeyInt(&key, ref.EndUnitID)

View File

@@ -32,7 +32,9 @@ type Registry struct {
digest string digest string
projectionDigest string projectionDigest string
promptInput contracts.LLMInputMaterial promptInput contracts.LLMInputMaterial
identityInput contracts.LLMInputMaterial
lookupByKey map[string]int lookupByKey map[string]int
lookupByID map[string]int
} }
// Resolver selects and memoizes immutable NPC registry views. // Resolver selects and memoizes immutable NPC registry views.
@@ -109,7 +111,9 @@ func emptyRegistry() *Registry {
canonical: append([]byte(nil), content...), canonical: append([]byte(nil), content...),
projectionDigest: projectionDigest, projectionDigest: projectionDigest,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""), promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
lookupByKey: map[string]int{}, lookupByKey: map[string]int{},
lookupByID: map[string]int{},
} }
} }
@@ -129,8 +133,10 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
list := cloneNPCRegistry(value) list := cloneNPCRegistry(value)
lookupByKey := make(map[string]int, len(list.NPCs)) lookupByKey := make(map[string]int, len(list.NPCs))
lookupByID := make(map[string]int, len(list.NPCs))
for index, npc := range list.NPCs { for index, npc := range list.NPCs {
lookupByKey[identity.ComparisonKey(npc.Name)] = index lookupByKey[identity.ComparisonKey(npc.Name)] = index
lookupByID[npc.ID] = index
} }
digest := semanticDigest(content) digest := semanticDigest(content)
projection, err := nameProjection(list) projection, err := nameProjection(list)
@@ -138,6 +144,11 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
return nil, fmt.Errorf("encode NPC name projection: %w", err) return nil, fmt.Errorf("encode NPC name projection: %w", err)
} }
projectionDigest := semanticDigest(projection) projectionDigest := semanticDigest(projection)
identityProjection, err := identityProjection(list)
if err != nil {
return nil, fmt.Errorf("encode NPC identity projection: %w", err)
}
identityProjectionDigest := semanticDigest(identityProjection)
return &Registry{ return &Registry{
bound: true, bound: true,
list: list, list: list,
@@ -146,6 +157,8 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
projectionDigest: projectionDigest, projectionDigest: projectionDigest,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""), promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
lookupByKey: lookupByKey, lookupByKey: lookupByKey,
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, identityProjection, identityProjectionDigest, ""),
lookupByID: lookupByID,
}, nil }, nil
} }
@@ -211,6 +224,15 @@ func (r *Registry) PromptInput() contracts.LLMInputMaterial {
return r.promptInput.Clone() return r.promptInput.Clone()
} }
// IdentityPromptInput returns the ordered ID/name projection for consumers
// that must bind output records to exact registry identities.
func (r *Registry) IdentityPromptInput() contracts.LLMInputMaterial {
if r == nil {
return contracts.LLMInputMaterial{}
}
return r.identityInput.Clone()
}
// Lookup returns the canonical NPC for an exact canonical-name match under the // Lookup returns the canonical NPC for an exact canonical-name match under the
// NPC identity comparison policy. // NPC identity comparison policy.
func (r *Registry) Lookup(value string) (dnd.NPC, bool) { func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
@@ -224,6 +246,18 @@ func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
return cloneNPC(r.list.NPCs[index]), true return cloneNPC(r.list.NPCs[index]), true
} }
// LookupID returns the canonical NPC for an exact durable ID.
func (r *Registry) LookupID(value string) (dnd.NPC, bool) {
if r == nil {
return dnd.NPC{}, false
}
index, ok := r.lookupByID[value]
if !ok {
return dnd.NPC{}, false
}
return cloneNPC(r.list.NPCs[index]), true
}
func semanticDigest(content []byte) string { func semanticDigest(content []byte) string {
sum := sha256.Sum256(content) sum := sha256.Sum256(content)
return "sha256:" + hex.EncodeToString(sum[:]) return "sha256:" + hex.EncodeToString(sum[:])
@@ -233,10 +267,19 @@ type projectedNPC struct {
Name string `json:"name"` Name string `json:"name"`
} }
type identityProjectedNPC struct {
ID string `json:"id"`
Name string `json:"name"`
}
type projectedNPCRegistry struct { type projectedNPCRegistry struct {
NPCs []projectedNPC `json:"npcs"` NPCs []projectedNPC `json:"npcs"`
} }
type identityProjectedNPCRegistry struct {
NPCs []identityProjectedNPC `json:"npcs"`
}
func nameProjection(list dnd.NPCRegistry) ([]byte, error) { func nameProjection(list dnd.NPCRegistry) ([]byte, error) {
projection := projectedNPCRegistry{NPCs: make([]projectedNPC, len(list.NPCs))} projection := projectedNPCRegistry{NPCs: make([]projectedNPC, len(list.NPCs))}
for index, npc := range list.NPCs { for index, npc := range list.NPCs {
@@ -245,6 +288,14 @@ func nameProjection(list dnd.NPCRegistry) ([]byte, error) {
return json.Marshal(projection) return json.Marshal(projection)
} }
func identityProjection(list dnd.NPCRegistry) ([]byte, error) {
projection := identityProjectedNPCRegistry{NPCs: make([]identityProjectedNPC, len(list.NPCs))}
for index, npc := range list.NPCs {
projection.NPCs[index] = identityProjectedNPC{ID: npc.ID, Name: npc.Name}
}
return json.Marshal(projection)
}
func formatIdentityIssues(issues []identity.Issue) string { func formatIdentityIssues(issues []identity.Issue) string {
parts := make([]string, len(issues)) parts := make([]string, len(issues))
for index, issue := range issues { for index, issue := range issues {

View File

@@ -19,7 +19,7 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
t.Fatalf("Resolve() error = %v", err) t.Fatalf("Resolve() error = %v", err)
} }
input := registry.PromptInput() input := registry.PromptInput()
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt { if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt || string(registry.IdentityPromptInput().Content) != emptyPrompt {
t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input) t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input)
} }
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" { if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
@@ -46,6 +46,21 @@ func TestResolveKeepsDurableProvenanceAndProjectsOnlyOrderedNames(t *testing.T)
} }
} }
func TestIdentityPromptInputProjectsOrderedIDsAndNames(t *testing.T) {
registry := resolveList(t, registryFixture())
input := registry.IdentityPromptInput()
want := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Captain Vale") + `","name":"Captain Vale"}]}`
if string(input.Content) != want || input.Digest == registry.ProjectionDigest() {
t.Fatalf("identity projection = %#v, want %s", input, want)
}
if npc, ok := registry.LookupID(identity.DeriveID("Mira Thorn")); !ok || npc.Name != "Mira Thorn" {
t.Fatalf("LookupID() = %#v, %t", npc, ok)
}
if _, ok := registry.LookupID("npc:unknown"); ok {
t.Fatal("LookupID() accepted an unknown ID")
}
}
func TestNameProjectionDigestTracksOnlyNamesAndOrder(t *testing.T) { func TestNameProjectionDigestTracksOnlyNamesAndOrder(t *testing.T) {
base := registryFixture() base := registryFixture()
evidenceChanged := registryFixture() evidenceChanged := registryFixture()

View File

@@ -20,7 +20,7 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error {
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence) return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence)
}}, }},
{name: "npc interactions evidence", register: func() error { {name: "npc interactions evidence", register: func() error {
return pipeline.RegisterArtifactEvidence(registry, dnd.NPCInteractionListKind, npcInteractionEvidence) return pipeline.RegisterArtifactEvidence(registry, dnd.NPCOccurrenceListKind, npcInteractionEvidence)
}}, }},
{name: "scene descriptions evidence", register: func() error { {name: "scene descriptions evidence", register: func() error {
return pipeline.RegisterArtifactEvidence(registry, dnd.SceneDescriptionListKind, sceneDescriptionEvidence) return pipeline.RegisterArtifactEvidence(registry, dnd.SceneDescriptionListKind, sceneDescriptionEvidence)
@@ -74,9 +74,9 @@ func itemEventEvidence(value dnd.ItemEventList) []source.SourceRef {
return append([]source.SourceRef(nil), refs...) return append([]source.SourceRef(nil), refs...)
} }
func npcInteractionEvidence(value dnd.NPCInteractionList) []source.SourceRef { func npcInteractionEvidence(value dnd.NPCOccurrenceList) []source.SourceRef {
var refs []source.SourceRef var refs []source.SourceRef
for _, record := range value.Interactions { for _, record := range value.Occurrences {
refs = append(refs, record.SourceRefs...) refs = append(refs, record.SourceRefs...)
} }
return append([]source.SourceRef(nil), refs...) return append([]source.SourceRef(nil), refs...)

View File

@@ -112,22 +112,22 @@ func appendItemEventLists(values []dnd.ItemEventList) (dnd.ItemEventList, error)
return combined, nil return combined, nil
} }
func appendNPCInteractionLists(values []dnd.NPCInteractionList) (dnd.NPCInteractionList, error) { func appendNPCInteractionLists(values []dnd.NPCOccurrenceList) (dnd.NPCOccurrenceList, error) {
count := 0 count := 0
present := false present := false
for _, value := range values { for _, value := range values {
if value.Interactions != nil { if value.Occurrences != nil {
present = true present = true
} }
count += len(value.Interactions) count += len(value.Occurrences)
} }
if !present { if !present {
return dnd.NPCInteractionList{}, nil return dnd.NPCOccurrenceList{}, nil
} }
combined := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, 0, count)} combined := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, 0, count)}
for _, value := range values { for _, value := range values {
for _, interaction := range value.Interactions { for _, occurrence := range value.Occurrences {
combined.Interactions = append(combined.Interactions, cloneNPCInteraction(interaction)) combined.Occurrences = append(combined.Occurrences, cloneNPCOccurrence(occurrence))
} }
} }
return combined, nil return combined, nil
@@ -228,7 +228,7 @@ func cloneNPC(value dnd.NPC) dnd.NPC {
return clone return clone
} }
func cloneNPCInteraction(value dnd.NPCInteraction) dnd.NPCInteraction { func cloneNPCOccurrence(value dnd.NPCOccurrence) dnd.NPCOccurrence {
clone := value clone := value
clone.SourceRefs = cloneSourceRefs(value.SourceRefs) clone.SourceRefs = cloneSourceRefs(value.SourceRefs)
return clone return clone

View File

@@ -79,7 +79,7 @@ func registerModules(registries pipeline.Registries) error {
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists) return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists)
}}, }},
{name: "npc-interaction-list appendorder merger", register: func() error { {name: "npc-interaction-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.NPCInteractionListKind, appendNPCInteractionLists) return appendorder.RegisterTyped(registries.Mergers, dnd.NPCOccurrenceListKind, appendNPCInteractionLists)
}}, }},
{name: "scene-description-list appendorder merger", register: func() error { {name: "scene-description-list appendorder merger", register: func() error {
return appendorder.RegisterTyped(registries.Mergers, dnd.SceneDescriptionListKind, appendSceneDescriptionLists) return appendorder.RegisterTyped(registries.Mergers, dnd.SceneDescriptionListKind, appendSceneDescriptionLists)
@@ -115,7 +115,7 @@ func registerModules(registries pipeline.Registries) error {
return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind) return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind)
}}, }},
{name: "npc-interaction-list noop normalizer", register: func() error { {name: "npc-interaction-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.NPCInteractionList](registries.Normalizers, dnd.NPCInteractionListKind) return noop.RegisterTyped[dnd.NPCOccurrenceList](registries.Normalizers, dnd.NPCOccurrenceListKind)
}}, }},
{name: "scene-description-list noop normalizer", register: func() error { {name: "scene-description-list noop normalizer", register: func() error {
return noop.RegisterTyped[dnd.SceneDescriptionList](registries.Normalizers, dnd.SceneDescriptionListKind) return noop.RegisterTyped[dnd.SceneDescriptionList](registries.Normalizers, dnd.SceneDescriptionListKind)

View File

@@ -84,15 +84,15 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"}) assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"})
assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, enemyeventextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key, locationextract.Key, locationoccurrenceextract.Key}) assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells", npcextract.Key, combatextract.Key, enemyeventextract.Key, itemeventextract.Key, interactionextract.Key, scenedescriptionextract.Key, locationextract.Key, locationoccurrenceextract.Key})
assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemeventnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, locationnormalize.Key, locationoccurrencenormalize.Key, pipeline.DefaultNormalizeModule}) assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, npcnormalize.Key, combatnormalize.Key, enemyeventnormalize.Key, itemeventnormalize.Key, interactionnormalize.Key, scenedescriptionnormalize.Key, locationnormalize.Key, locationoccurrencenormalize.Key, pipeline.DefaultNormalizeModule})
assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) assertContainsArtifactKinds(t, registries.ArtifactEvidence.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) assertContainsArtifactKinds(t, registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCInteractionListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind, dnd.NPCRegistryKind, dnd.CombatTurnListKind, dnd.EnemyEventListKind, dnd.ItemEventListKind, dnd.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCRegistryKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(npcnormalize.Key), []contracts.ArtifactKind{dnd.NPCRegistryKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(combatnormalize.Key), []contracts.ArtifactKind{dnd.CombatTurnListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(itemeventnormalize.Key), []contracts.ArtifactKind{dnd.ItemEventListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCInteractionListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(interactionnormalize.Key), []contracts.ArtifactKind{dnd.NPCOccurrenceListKind})
assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind}) assertContainsArtifactKinds(t, registries.Normalizers.RegisteredArtifactKinds(scenedescriptionnormalize.Key), []contracts.ArtifactKind{dnd.SceneDescriptionListKind})
assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{
"extract/dnd/locations/shape", "normalize/dnd/locations/identity", "extract/dnd/locations/source_refs", "extract/dnd/locations/source_relatedness", "extract/dnd/locations/shape", "normalize/dnd/locations/identity", "extract/dnd/locations/source_refs", "extract/dnd/locations/source_relatedness",
@@ -302,7 +302,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
"dnd_combat_turns_llm.v1.json", "dnd_combat_turns_llm.v1.json",
"dnd_enemy_events_llm.v1.json", "dnd_enemy_events_llm.v1.json",
"dnd_item_events_llm.v1.json", "dnd_item_events_llm.v1.json",
"dnd_npc_interactions_llm.v1.json", "dnd_npc_occurrences_llm.v1.json",
"dnd_scene_descriptions_llm.v1.json", "dnd_scene_descriptions_llm.v1.json",
"dnd_locations_llm.v1.json", "dnd_locations_llm.v1.json",
"dnd_location_occurrences_llm.v1.json", "dnd_location_occurrences_llm.v1.json",
@@ -353,7 +353,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
} }
interactionExtractSpec, extractOK := registries.Extractors.Spec(interactionextract.Key) interactionExtractSpec, extractOK := registries.Extractors.Spec(interactionextract.Key)
interactionNormalizeSpec, normalizeOK := registries.Normalizers.Spec(interactionnormalize.Key) interactionNormalizeSpec, normalizeOK := registries.Normalizers.Spec(interactionnormalize.Key)
if !extractOK || interactionExtractSpec.ArtifactKind != dnd.NPCInteractionListKind || !normalizeOK || interactionNormalizeSpec.ArtifactKind != dnd.NPCInteractionListKind || interactionNormalizeSpec.Stage != pipeline.StageNormalize { if !extractOK || interactionExtractSpec.ArtifactKind != dnd.NPCOccurrenceListKind || !normalizeOK || interactionNormalizeSpec.ArtifactKind != dnd.NPCOccurrenceListKind || interactionNormalizeSpec.Stage != pipeline.StageNormalize {
t.Fatalf("NPC interaction specs = %#v / %#v, present = %t / %t", interactionExtractSpec, interactionNormalizeSpec, extractOK, normalizeOK) t.Fatalf("NPC interaction specs = %#v / %#v, present = %t / %t", interactionExtractSpec, interactionNormalizeSpec, extractOK, normalizeOK)
} }
locationExtractSpec, locationExtractOK := registries.Extractors.Spec(locationextract.Key) locationExtractSpec, locationExtractOK := registries.Extractors.Spec(locationextract.Key)
@@ -419,7 +419,7 @@ func TestEvidenceProjectorsPreserveDirectReferencesWithIndependentStorage(t *tes
return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}}) return itemEventEvidence(dnd.ItemEventList{Events: []dnd.ItemEvent{{SourceRefs: []source.SourceRef{first, second}}}})
}, want: []source.SourceRef{first, second}}, }, want: []source.SourceRef{first, second}},
{name: "npc interactions", project: func() []source.SourceRef { {name: "npc interactions", project: func() []source.SourceRef {
return npcInteractionEvidence(dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{first, second}}}}) return npcInteractionEvidence(dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{SourceRefs: []source.SourceRef{first, second}}}})
}, want: []source.SourceRef{first, second}}, }, want: []source.SourceRef{first, second}},
{name: "scene descriptions", project: func() []source.SourceRef { {name: "scene descriptions", project: func() []source.SourceRef {
return sceneDescriptionEvidence(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{SourceRef: first}, {SourceRef: second}}}) return sceneDescriptionEvidence(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{SourceRef: first}, {SourceRef: second}}})
@@ -561,26 +561,26 @@ func TestAppendSpellListsPreservesOrderPresenceAndOwnership(t *testing.T) {
func TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing.T) { func TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing.T) {
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}} refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
input := []dnd.NPCInteractionList{ input := []dnd.NPCOccurrenceList{
{}, {},
{Interactions: []dnd.NPCInteraction{}}, {Occurrences: []dnd.NPCOccurrence{}},
{Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: refs}}}, {Occurrences: []dnd.NPCOccurrence{{Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: refs}}},
{Interactions: []dnd.NPCInteraction{{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}}, {Occurrences: []dnd.NPCOccurrence{{Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}},
} }
got, err := appendNPCInteractionLists(input) got, err := appendNPCInteractionLists(input)
if err != nil { if err != nil {
t.Fatalf("appendNPCInteractionLists() error = %v", err) t.Fatalf("appendNPCInteractionLists() error = %v", err)
} }
if got.Interactions == nil || !reflect.DeepEqual([]string{got.Interactions[0].Name, got.Interactions[1].Name}, []string{"Aria", "Borin"}) { if got.Occurrences == nil || !reflect.DeepEqual([]string{got.Occurrences[0].Name, got.Occurrences[1].Name}, []string{"Aria", "Borin"}) {
t.Fatalf("combined interactions = %#v", got) t.Fatalf("combined interactions = %#v", got)
} }
got.Interactions[0].SourceRefs[0].StartUnitID = 999 got.Occurrences[0].SourceRefs[0].StartUnitID = 999
if input[2].Interactions[0].SourceRefs[0].StartUnitID == 999 { if input[2].Occurrences[0].SourceRefs[0].StartUnitID == 999 {
t.Fatal("merged interactions share source reference storage") t.Fatal("merged interactions share source reference storage")
} }
for _, values := range [][]dnd.NPCInteractionList{nil, []dnd.NPCInteractionList{{}, {}}} { for _, values := range [][]dnd.NPCOccurrenceList{nil, []dnd.NPCOccurrenceList{{}, {}}} {
result, err := appendNPCInteractionLists(values) result, err := appendNPCInteractionLists(values)
if err != nil || result.Interactions != nil { if err != nil || result.Occurrences != nil {
t.Fatalf("nil-only merge = %#v, %v; want nil interactions", result, err) t.Fatalf("nil-only merge = %#v, %v; want nil interactions", result, err)
} }
} }
@@ -711,8 +711,8 @@ func TestAppendListsPreserveNestedSourceReferencePresence(t *testing.T) {
t.Fatalf("appendEnemyEventLists() = %#v, %v; want present-empty source refs", enemyEvents, err) t.Fatalf("appendEnemyEventLists() = %#v, %v; want present-empty source refs", enemyEvents, err)
} }
interactions, err := appendNPCInteractionLists([]dnd.NPCInteractionList{{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{}}}}}) interactions, err := appendNPCInteractionLists([]dnd.NPCOccurrenceList{{Occurrences: []dnd.NPCOccurrence{{SourceRefs: []source.SourceRef{}}}}})
if err != nil || interactions.Interactions[0].SourceRefs == nil { if err != nil || interactions.Occurrences[0].SourceRefs == nil {
t.Fatalf("appendNPCInteractionLists() = %#v, %v; want present-empty source refs", interactions, err) t.Fatalf("appendNPCInteractionLists() = %#v, %v; want present-empty source refs", interactions, err)
} }

View File

@@ -118,10 +118,10 @@ func registerValidators(registries pipeline.Registries) error {
return alwaysreject.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind) return alwaysreject.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind)
}}, }},
{name: "npc-interaction-list always accept validator", register: func() error { {name: "npc-interaction-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind) return alwaysaccept.RegisterTyped[dnd.NPCOccurrenceList](registries.Validators, dnd.NPCOccurrenceListKind)
}}, }},
{name: "npc-interaction-list always reject validator", register: func() error { {name: "npc-interaction-list always reject validator", register: func() error {
return alwaysreject.RegisterTyped[dnd.NPCInteractionList](registries.Validators, dnd.NPCInteractionListKind) return alwaysreject.RegisterTyped[dnd.NPCOccurrenceList](registries.Validators, dnd.NPCOccurrenceListKind)
}}, }},
{name: "scene-description-list always accept validator", register: func() error { {name: "scene-description-list always accept validator", register: func() error {
return alwaysaccept.RegisterTyped[dnd.SceneDescriptionList](registries.Validators, dnd.SceneDescriptionListKind) return alwaysaccept.RegisterTyped[dnd.SceneDescriptionList](registries.Validators, dnd.SceneDescriptionListKind)

View File

@@ -12,7 +12,7 @@ const NPCRegistryKind contracts.ArtifactKind = "dnd/npc-registry"
const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list" const CombatTurnListKind contracts.ArtifactKind = "dnd/combat-turn-list"
const NPCInteractionListKind contracts.ArtifactKind = "dnd/npc-interaction-list" const NPCOccurrenceListKind contracts.ArtifactKind = "dnd/npc-occurrence-list"
const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list" const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list"
@@ -64,24 +64,25 @@ type CombatTurn struct {
SourceRefs []source.SourceRef `json:"source_refs"` SourceRefs []source.SourceRef `json:"source_refs"`
} }
type NPCInteractionKind string type NPCOccurrenceKind string
const ( const (
NPCInteractionKindMentioned NPCInteractionKind = "mentioned" NPCOccurrenceKindMentioned NPCOccurrenceKind = "mentioned"
NPCInteractionKindNoncombatPresence NPCInteractionKind = "noncombat_presence" NPCOccurrenceKindNoncombatPresence NPCOccurrenceKind = "noncombat_presence"
NPCInteractionKindDialogue NPCInteractionKind = "dialogue" NPCOccurrenceKindDialogue NPCOccurrenceKind = "dialogue"
NPCInteractionKindCombatAlly NPCInteractionKind = "combat_ally" NPCOccurrenceKindCombatAlly NPCOccurrenceKind = "combat_ally"
NPCInteractionKindCombatOpponent NPCInteractionKind = "combat_opponent" NPCOccurrenceKindCombatOpponent NPCOccurrenceKind = "combat_opponent"
NPCInteractionKindOther NPCInteractionKind = "other" NPCOccurrenceKindOther NPCOccurrenceKind = "other"
) )
type NPCInteractionList struct { type NPCOccurrenceList struct {
Interactions []NPCInteraction `json:"interactions"` Occurrences []NPCOccurrence `json:"occurrences"`
} }
type NPCInteraction struct { type NPCOccurrence struct {
NPCID string `json:"npc_id"`
Name string `json:"name"` Name string `json:"name"`
Kind NPCInteractionKind `json:"kind"` Kind NPCOccurrenceKind `json:"kind"`
SourceRefs []source.SourceRef `json:"source_refs"` SourceRefs []source.SourceRef `json:"source_refs"`
} }

View File

@@ -29,7 +29,7 @@ type Validator struct {
npcResolver *npcregistry.Resolver npcResolver *npcregistry.Resolver
} }
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
var _ contracts.ManifestMetadataProvider = (*Validator)(nil) var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
@@ -72,11 +72,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
} }
return []pipeline.CheckpointFingerprint{ return []pipeline.CheckpointFingerprint{
{Name: "policy", Value: policy}, {Name: "policy", Value: policy},
{Name: "npc_registry", Value: v.npcResolver.Seeded().ProjectionDigest()}, {Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
} }
} }
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
if interactionshape.Validate(req.Value) != nil { if interactionshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
@@ -105,25 +105,28 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}, nil }, nil
} }
func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCInteractionList) bool { func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCOccurrenceList) bool {
for _, interaction := range value.Interactions { for _, occurrence := range value.Occurrences {
if !interactionmodel.ValidSourceRefs(index, interaction.SourceRefs) { if !interactionmodel.ValidSourceRefs(index, occurrence.SourceRefs) {
return false return false
} }
} }
return true return true
} }
func issuesFor(order shared.SourceRefOrder, value dnd.NPCInteractionList, npcRegistry *npcregistry.Registry) []string { func issuesFor(order shared.SourceRefOrder, value dnd.NPCOccurrenceList, npcRegistry *npcregistry.Registry) []string {
issues := make([]string, 0) issues := make([]string, 0)
for index, interaction := range value.Interactions { for index, occurrence := range value.Occurrences {
prefix := fmt.Sprintf("interactions[%d]", index) prefix := fmt.Sprintf("occurrences[%d]", index)
if canonical, ok := npcRegistry.Lookup(interaction.Name); ok && interaction.Name != canonical.Name { canonical, ok := npcRegistry.LookupID(occurrence.NPCID)
issues = append(issues, prefix+".name is not the canonical NPC display name: "+diagnostics.Quote(interaction.Name)) if !ok {
issues = append(issues, prefix+".npc_id is not in the NPC registry: "+diagnostics.Quote(occurrence.NPCID))
} else if occurrence.Name != canonical.Name {
issues = append(issues, prefix+".name does not match npc_id: "+diagnostics.Quote(occurrence.Name))
} }
for refIndex := 1; refIndex < len(interaction.SourceRefs); refIndex++ { for refIndex := 1; refIndex < len(occurrence.SourceRefs); refIndex++ {
previous := interaction.SourceRefs[refIndex-1] previous := occurrence.SourceRefs[refIndex-1]
current := interaction.SourceRefs[refIndex] current := occurrence.SourceRefs[refIndex]
if order.Less(current, previous) { if order.Less(current, previous) {
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex)) issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
} else if current == previous { } else if current == previous {
@@ -132,16 +135,16 @@ func issuesFor(order shared.SourceRefOrder, value dnd.NPCInteractionList, npcReg
} }
} }
if !sort.SliceIsSorted(value.Interactions, func(left, right int) bool { if !sort.SliceIsSorted(value.Occurrences, func(left, right int) bool {
return interactionmodel.Less(order, value.Interactions[left], value.Interactions[right]) return interactionmodel.Less(order, value.Occurrences[left], value.Occurrences[right])
}) { }) {
issues = append(issues, "interactions are not in canonical order") issues = append(issues, "occurrences are not in canonical order")
} }
seen := make(map[string]int) seen := make(map[string]int)
for index, interaction := range value.Interactions { for index, occurrence := range value.Occurrences {
key := interactionmodel.ExactIdentity(interaction) key := interactionmodel.ExactIdentity(occurrence)
if previous, ok := seen[key]; ok { if previous, ok := seen[key]; ok {
issues = append(issues, fmt.Sprintf("interactions[%d] duplicates interaction %d", index, previous)) issues = append(issues, fmt.Sprintf("occurrences[%d] duplicates occurrence %d", index, previous))
continue continue
} }
seen[key] = index seen[key] = index
@@ -154,7 +157,7 @@ func Spec() pipeline.ValidatorSpec {
} }
func Register(registry *pipeline.ValidatorRegistry) error { func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) { return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) {
options, err := DecodeOptions(request.Options) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -28,26 +28,26 @@ func TestValidatorRejectsOwnedCanonicalNameReferenceOrderListOrderAndDuplicates(
references := registryReferences(t, "Aria", "Borin") references := registryReferences(t, "Aria", "Borin")
for _, test := range []struct { for _, test := range []struct {
name string name string
mutate func(*dnd.NPCInteractionList) mutate func(*dnd.NPCOccurrenceList)
want string want string
}{ }{
{"canonical name", func(value *dnd.NPCInteractionList) { value.Interactions[0].Name = " aria " }, "canonical NPC display name"}, {"mismatched name", func(value *dnd.NPCOccurrenceList) { value.Occurrences[0].Name = " aria " }, "does not match npc_id"},
{"reference order", func(value *dnd.NPCInteractionList) { {"reference order", func(value *dnd.NPCOccurrenceList) {
value.Interactions[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}} value.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
}, "not in canonical order"}, }, "not in canonical order"},
{"duplicate reference", func(value *dnd.NPCInteractionList) { {"duplicate reference", func(value *dnd.NPCOccurrenceList) {
value.Interactions[0].SourceRefs = append(value.Interactions[0].SourceRefs, value.Interactions[0].SourceRefs[0]) value.Occurrences[0].SourceRefs = append(value.Occurrences[0].SourceRefs, value.Occurrences[0].SourceRefs[0])
}, "duplicates the previous reference"}, }, "duplicates the previous reference"},
{"list order", func(value *dnd.NPCInteractionList) { {"list order", func(value *dnd.NPCOccurrenceList) {
value.Interactions[0], value.Interactions[1] = value.Interactions[1], value.Interactions[0] value.Occurrences[0], value.Occurrences[1] = value.Occurrences[1], value.Occurrences[0]
}, "interactions are not in canonical order"}, }, "occurrences are not in canonical order"},
{"name tie breaker", func(value *dnd.NPCInteractionList) { {"NPC ID tie breaker", func(value *dnd.NPCOccurrenceList) {
value.Interactions[0] = dnd.NPCInteraction{Name: "Borin", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}} value.Occurrences[0] = dnd.NPCOccurrence{NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}
value.Interactions[1] = dnd.NPCInteraction{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}} value.Occurrences[1] = dnd.NPCOccurrence{NPCID: identity.DeriveID("Borin"), Name: "Borin", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}
}, "interactions are not in canonical order"}, }, "occurrences are not in canonical order"},
{"duplicate record", func(value *dnd.NPCInteractionList) { {"duplicate record", func(value *dnd.NPCOccurrenceList) {
value.Interactions = append(value.Interactions, value.Interactions[0]) value.Occurrences = append(value.Occurrences, value.Occurrences[0])
}, "duplicates interaction"}, }, "duplicates occurrence"},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
value := normalizedList() value := normalizedList()
@@ -62,9 +62,9 @@ func TestValidatorRejectsOwnedCanonicalNameReferenceOrderListOrderAndDuplicates(
func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) { func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) {
references := registryReferences(t, "Aria", "Borin") references := registryReferences(t, "Aria", "Borin")
value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ value := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, {NPCID: "npc:test", Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}, {NPCID: "npc:test", Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}},
}} }}
result, err := newValidator(t, references).Validate(context.Background(), request(references, value)) result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
if err != nil || !result.Approved { if err != nil || !result.Approved {
@@ -74,9 +74,9 @@ func TestValidatorAcceptsValidEvidenceBeforeInvalidEvidence(t *testing.T) {
func TestValidatorDefersShapeAndSourceReferenceFailuresAndRequiresRegistry(t *testing.T) { func TestValidatorDefersShapeAndSourceReferenceFailuresAndRequiresRegistry(t *testing.T) {
references := registryReferences(t, "Aria", "Borin") references := registryReferences(t, "Aria", "Borin")
for _, value := range []dnd.NPCInteractionList{ for _, value := range []dnd.NPCOccurrenceList{
{Interactions: []dnd.NPCInteraction{{Name: "Aria"}}}, {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Aria"}}},
{Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}, {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}},
} { } {
result, err := newValidator(t, references).Validate(context.Background(), request(references, value)) result, err := newValidator(t, references).Validate(context.Background(), request(references, value))
if err != nil || !result.Approved { if err != nil || !result.Approved {
@@ -126,26 +126,26 @@ func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator
return validator return validator
} }
func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] { func request(references contracts.ReferenceSet, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] {
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: document(), References: references, Value: value} return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: document(), References: references, Value: value}
} }
func document() *source.SourceDocument { func document() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}} return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}}}
} }
func normalizedList() dnd.NPCInteractionList { func normalizedList() dnd.NPCOccurrenceList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}, {NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}, {NPCID: identity.DeriveID("Borin"), Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
}} }}
} }
func cloneList(value dnd.NPCInteractionList) dnd.NPCInteractionList { func cloneList(value dnd.NPCOccurrenceList) dnd.NPCOccurrenceList {
copyValue := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, len(value.Interactions))} copyValue := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, len(value.Occurrences))}
for index, interaction := range value.Interactions { for index, interaction := range value.Occurrences {
copyValue.Interactions[index] = interaction copyValue.Occurrences[index] = interaction
copyValue.Interactions[index].SourceRefs = append([]source.SourceRef(nil), interaction.SourceRefs...) copyValue.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), interaction.SourceRefs...)
} }
return copyValue return copyValue
} }

View File

@@ -25,7 +25,7 @@ type Validator struct {
npcResolver *npcregistry.Resolver npcResolver *npcregistry.Resolver
} }
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
var _ contracts.ManifestMetadataProvider = (*Validator)(nil) var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
@@ -68,11 +68,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
} }
return []pipeline.CheckpointFingerprint{ return []pipeline.CheckpointFingerprint{
{Name: "policy", Value: policy}, {Name: "policy", Value: policy},
{Name: "npc_registry", Value: v.npcResolver.Seeded().ProjectionDigest()}, {Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
} }
} }
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
if interactionshape.Validate(req.Value) != nil { if interactionshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
@@ -87,9 +87,14 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
return rejection([]string{"NPC registry reference is required"}), nil return rejection([]string{"NPC registry reference is required"}), nil
} }
issues := make([]string, 0) issues := make([]string, 0)
for index, interaction := range req.Value.Interactions { for index, occurrence := range req.Value.Occurrences {
if _, ok := npcRegistry.Lookup(interaction.Name); !ok { canonical, ok := npcRegistry.LookupID(occurrence.NPCID)
issues = append(issues, fmt.Sprintf("interactions[%d].name is not in the NPC registry: %s", index, diagnostics.Quote(interaction.Name))) if !ok {
issues = append(issues, fmt.Sprintf("occurrences[%d].npc_id is not in the NPC registry: %s", index, diagnostics.Quote(occurrence.NPCID)))
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)))
} }
} }
if len(issues) == 0 { if len(issues) == 0 {
@@ -111,7 +116,7 @@ func Spec() pipeline.ValidatorSpec {
} }
func Register(registry *pipeline.ValidatorRegistry) error { func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) { return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) {
options, err := DecodeOptions(request.Options) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -16,19 +16,25 @@ import (
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry" npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
) )
func TestValidatorRecognizesRegistryNamesAndRejectsUnknownNames(t *testing.T) { func TestValidatorRecognizesExactRegistryPairsAndRejectsUnknownIDs(t *testing.T) {
references := registryReferences(t, "Mira Thorn") references := registryReferences(t, "Mira Thorn")
validator := newValidator(t, references) validator := newValidator(t, references)
value := validList(" mira thorn ") value := validList("Mira Thorn")
result, err := validator.Validate(context.Background(), request(references, value)) result, err := validator.Validate(context.Background(), request(references, value))
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("recognized result = %#v, %v", result, err) t.Fatalf("recognized result = %#v, %v", result, err)
} }
value.Interactions[0].Name = "Unknown NPC" value.Occurrences[0].NPCID = "npc:unknown"
result, err = validator.Validate(context.Background(), request(references, value)) result, err = validator.Validate(context.Background(), request(references, value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "interactions[0].name") { if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "occurrences[0].npc_id") {
t.Fatalf("unknown result = %#v, %v", result, err) t.Fatalf("unknown result = %#v, %v", result, err)
} }
value = validList("Mira Thorn")
value.Occurrences[0].Name = "Hooded Guard"
result, err = validator.Validate(context.Background(), request(references, value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "does not match npc_id") {
t.Fatalf("mismatched result = %#v, %v", result, err)
}
} }
func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(t *testing.T) { func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(t *testing.T) {
@@ -45,7 +51,7 @@ func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(t
} }
empty := registryReferences(t) empty := registryReferences(t)
result, err = newValidator(t, empty).Validate(context.Background(), request(empty, dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}})) result, err = newValidator(t, empty).Validate(context.Background(), request(empty, dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{}}))
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("empty registry result = %#v, %v", result, err) t.Fatalf("empty registry result = %#v, %v", result, err)
} }
@@ -78,7 +84,7 @@ func TestValidatorRejectsMalformedRegistryWithoutContentAndKeepsMetadataSafe(t *
func TestValidatorDefersShapeAndDoesNotMutateOrMisregister(t *testing.T) { func TestValidatorDefersShapeAndDoesNotMutateOrMisregister(t *testing.T) {
references := registryReferences(t, "Mira Thorn") references := registryReferences(t, "Mira Thorn")
validator := newValidator(t, references) validator := newValidator(t, references)
malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}} malformed := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn"}}}
result, err := validator.Validate(context.Background(), request(references, malformed)) result, err := validator.Validate(context.Background(), request(references, malformed))
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("shape deferral = %#v, %v", result, err) t.Fatalf("shape deferral = %#v, %v", result, err)
@@ -107,13 +113,13 @@ func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator
return validator return validator
} }
func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] { func request(references contracts.ReferenceSet, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] {
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{References: references, Value: value} return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{References: references, Value: value}
} }
func validList(name string) dnd.NPCInteractionList { func validList(name string) dnd.NPCOccurrenceList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
Name: name, Kind: dnd.NPCInteractionKindDialogue, NPCID: identity.DeriveID(name), Name: name, Kind: dnd.NPCOccurrenceKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
} }

View File

@@ -21,7 +21,7 @@ const (
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } func New(Options) *Validator { return &Validator{} }
@@ -33,14 +33,14 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
} }
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
if err := Validate(req.Value); err != nil { if err := Validate(req.Value); err != nil {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
} }
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
func Validate(value dnd.NPCInteractionList) error { func Validate(value dnd.NPCOccurrenceList) error {
issues := issuesFor(value) issues := issuesFor(value)
if len(issues) == 0 { if len(issues) == 0 {
return nil return nil
@@ -48,34 +48,37 @@ func Validate(value dnd.NPCInteractionList) error {
return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC interaction shape", issues)) return fmt.Errorf("%s", diagnostics.Aggregate("invalid NPC interaction shape", issues))
} }
func issuesFor(value dnd.NPCInteractionList) []string { func issuesFor(value dnd.NPCOccurrenceList) []string {
if value.Interactions == nil { if value.Occurrences == nil {
return []string{"interactions must be present"} return []string{"occurrences must be present"}
} }
issues := make([]string, 0) issues := make([]string, 0)
for index, interaction := range value.Interactions { for index, occurrence := range value.Occurrences {
prefix := fmt.Sprintf("interactions[%d]", index) prefix := fmt.Sprintf("occurrences[%d]", index)
if strings.TrimSpace(interaction.Name) == "" { if strings.TrimSpace(occurrence.NPCID) == "" {
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(interaction.Name)) issues = append(issues, prefix+".npc_id must not be empty: "+diagnostics.Quote(occurrence.NPCID))
} }
if !validKind(interaction.Kind) { if strings.TrimSpace(occurrence.Name) == "" {
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(interaction.Kind))) issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(occurrence.Name))
} }
if len(interaction.SourceRefs) == 0 { if !validKind(occurrence.Kind) {
issues = append(issues, prefix+".kind is unsupported: "+diagnostics.Quote(string(occurrence.Kind)))
}
if len(occurrence.SourceRefs) == 0 {
issues = append(issues, prefix+".source_refs must contain at least one reference") issues = append(issues, prefix+".source_refs must contain at least one reference")
} }
} }
return issues return issues
} }
func validKind(value dnd.NPCInteractionKind) bool { func validKind(value dnd.NPCOccurrenceKind) bool {
switch value { switch value {
case dnd.NPCInteractionKindMentioned, case dnd.NPCOccurrenceKindMentioned,
dnd.NPCInteractionKindNoncombatPresence, dnd.NPCOccurrenceKindNoncombatPresence,
dnd.NPCInteractionKindDialogue, dnd.NPCOccurrenceKindDialogue,
dnd.NPCInteractionKindCombatAlly, dnd.NPCOccurrenceKindCombatAlly,
dnd.NPCInteractionKindCombatOpponent, dnd.NPCOccurrenceKindCombatOpponent,
dnd.NPCInteractionKindOther: dnd.NPCOccurrenceKindOther:
return true return true
default: default:
return false return false
@@ -87,7 +90,7 @@ func Spec() pipeline.ValidatorSpec {
} }
func Register(registry *pipeline.ValidatorRegistry) error { func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) { return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) {
options, err := DecodeOptions(request.Options) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -15,22 +15,22 @@ import (
func TestValidatorOwnsRequiredNameKindAndEvidence(t *testing.T) { func TestValidatorOwnsRequiredNameKindAndEvidence(t *testing.T) {
valid := validList() valid := validList()
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Value: valid}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Value: valid})
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v", result, err) t.Fatalf("Validate() = %#v, %v", result, err)
} }
for _, test := range []struct { for _, test := range []struct {
name string name string
value dnd.NPCInteractionList value dnd.NPCOccurrenceList
want string want string
}{ }{
{"missing interactions", dnd.NPCInteractionList{}, "interactions must be present"}, {"missing occurrences", dnd.NPCOccurrenceList{}, "occurrences must be present"},
{"blank name", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: valid.Interactions[0].SourceRefs}}}, "name must not be empty"}, {"blank name", dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: valid.Occurrences[0].SourceRefs}}}, "name must not be empty"},
{"unsupported kind", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: "unsupported", SourceRefs: valid.Interactions[0].SourceRefs}}}, "kind is unsupported"}, {"unsupported kind", dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: "unsupported", SourceRefs: valid.Occurrences[0].SourceRefs}}}, "kind is unsupported"},
{"missing evidence", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue}}}, "source_refs must contain"}, {"missing evidence", dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue}}}, "source_refs must contain"},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Value: test.value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Value: test.value})
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) { if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want) t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want)
} }
@@ -39,15 +39,15 @@ func TestValidatorOwnsRequiredNameKindAndEvidence(t *testing.T) {
} }
func TestValidatorBoundsDiagnosticsPreservesValueAndRegistersTypedContract(t *testing.T) { func TestValidatorBoundsDiagnosticsPreservesValueAndRegistersTypedContract(t *testing.T) {
value := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, 24)} value := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, 24)}
for index := range value.Interactions { for index := range value.Occurrences {
value.Interactions[index] = dnd.NPCInteraction{Name: strings.Repeat("火", 220) + "\n", Kind: "unsupported"} value.Occurrences[index] = dnd.NPCOccurrence{NPCID: "npc:test", Name: strings.Repeat("火", 220) + "\n", Kind: "unsupported"}
} }
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Value: value})
if err != nil || result.Approved || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "additional issue(s) omitted") { if err != nil || result.Approved || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "additional issue(s) omitted") {
t.Fatalf("Validate() = %#v, %v", result, err) t.Fatalf("Validate() = %#v, %v", result, err)
} }
if value.Interactions[0].Name != strings.Repeat("火", 220)+"\n" { if value.Occurrences[0].Name != strings.Repeat("火", 220)+"\n" {
t.Fatal("Validate() mutated input") t.Fatal("Validate() mutated input")
} }
if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) { if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) {
@@ -62,9 +62,9 @@ func TestValidatorBoundsDiagnosticsPreservesValueAndRegistersTypedContract(t *te
} }
} }
func validList() dnd.NPCInteractionList { func validList() dnd.NPCOccurrenceList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}} }}}
} }

View File

@@ -22,7 +22,7 @@ const (
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } func New(Options) *Validator { return &Validator{} }
@@ -34,7 +34,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
} }
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil { if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil {
return contracts.ValidationResult{}, fmt.Errorf("NPC interaction source-reference validator requires the current extraction chunk") return contracts.ValidationResult{}, fmt.Errorf("NPC interaction source-reference validator requires the current extraction chunk")
} }
@@ -43,16 +43,16 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
} }
index := source.NewDocumentIndex(req.Source) index := source.NewDocumentIndex(req.Source)
issues := make([]string, 0) issues := make([]string, 0)
for interactionIndex, interaction := range req.Value.Interactions { for occurrenceIndex, occurrence := range req.Value.Occurrences {
for refIndex, ref := range interaction.SourceRefs { for refIndex, ref := range occurrence.SourceRefs {
if err := index.ValidateRef(ref); err != nil { if err := index.ValidateRef(ref); err != nil {
issues = append(issues, fmt.Sprintf("interactions[%d].source_refs[%d]: %s", interactionIndex, refIndex, diagnostics.Truncate(err.Error()))) issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", occurrenceIndex, refIndex, diagnostics.Truncate(err.Error())))
continue continue
} }
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) { if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
issues = append(issues, fmt.Sprintf( issues = append(issues, fmt.Sprintf(
"interactions[%d].source_refs[%d]: source reference is outside the current extraction chunk", "occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk",
interactionIndex, refIndex, occurrenceIndex, refIndex,
)) ))
} }
} }
@@ -85,7 +85,7 @@ func Spec() pipeline.ValidatorSpec {
} }
func Register(registry *pipeline.ValidatorRegistry) error { func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) { return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) {
options, err := DecodeOptions(request.Options) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -18,13 +18,13 @@ func TestValidatorOwnsCurrentSourceUnitAndRangeValidation(t *testing.T) {
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("valid result = %#v, %v", result, err) t.Fatalf("valid result = %#v, %v", result, err)
} }
value.Interactions[0].SourceRefs = []source.SourceRef{ value.Occurrences[0].SourceRefs = []source.SourceRef{
{SourceID: "foreign", StartUnitID: 1, EndUnitID: 1}, {SourceID: "foreign", StartUnitID: 1, EndUnitID: 1},
{SourceID: "session", StartUnitID: 99, EndUnitID: 99}, {SourceID: "session", StartUnitID: 99, EndUnitID: 99},
{SourceID: "session", StartUnitID: 2, EndUnitID: 1}, {SourceID: "session", StartUnitID: 2, EndUnitID: 1},
} }
result, err = New(Options{}).Validate(context.Background(), request(document(), value)) result, err = New(Options{}).Validate(context.Background(), request(document(), value))
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "interactions[0].source_refs[0]") { if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, "occurrences[0].source_refs[0]") {
t.Fatalf("invalid result = %#v, %v", result, err) t.Fatalf("invalid result = %#v, %v", result, err)
} }
} }
@@ -37,7 +37,7 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t
Units: append([]source.SourceUnit(nil), doc.Units[:2]...), Units: append([]source.SourceUnit(nil), doc.Units[:2]...),
} }
value := validList() value := validList()
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{ result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{
Stage: string(pipeline.StageExtract), Stage: string(pipeline.StageExtract),
Source: doc, Source: doc,
Chunk: chunk, Chunk: chunk,
@@ -47,10 +47,10 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t
t.Fatalf("contained evidence = %#v, %v", result, err) t.Fatalf("contained evidence = %#v, %v", result, err)
} }
value.Interactions[0].SourceRefs = []source.SourceRef{{ value.Occurrences[0].SourceRefs = []source.SourceRef{{
SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3, SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3,
}} }}
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{ result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{
Stage: string(pipeline.StageExtract), Stage: string(pipeline.StageExtract),
Source: doc, Source: doc,
Chunk: chunk, Chunk: chunk,
@@ -62,7 +62,7 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t
} }
func TestValidatorRequiresChunkDuringExtractValidation(t *testing.T) { func TestValidatorRequiresChunkDuringExtractValidation(t *testing.T) {
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{ _, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{
Stage: string(pipeline.StageExtract), Stage: string(pipeline.StageExtract),
Source: document(), Source: document(),
Value: validList(), Value: validList(),
@@ -73,7 +73,7 @@ func TestValidatorRequiresChunkDuringExtractValidation(t *testing.T) {
} }
func TestValidatorDefersShapeAndDoesNotMutate(t *testing.T) { func TestValidatorDefersShapeAndDoesNotMutate(t *testing.T) {
malformed := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}} malformed := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn"}}}
result, err := New(Options{}).Validate(context.Background(), request(document(), malformed)) result, err := New(Options{}).Validate(context.Background(), request(document(), malformed))
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("shape deferral = %#v, %v", result, err) t.Fatalf("shape deferral = %#v, %v", result, err)
@@ -93,17 +93,17 @@ func TestValidatorDefersShapeAndDoesNotMutate(t *testing.T) {
} }
} }
func request(doc *source.SourceDocument, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] { func request(doc *source.SourceDocument, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] {
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: doc, Value: value} return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: doc, Value: value}
} }
func document() *source.SourceDocument { func document() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}} return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}}
} }
func validList() dnd.NPCInteractionList { func validList() dnd.NPCOccurrenceList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{ return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}},
}}} }}}
} }

View File

@@ -23,7 +23,7 @@ const (
type Options struct{} type Options struct{}
type Validator struct{} type Validator struct{}
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil) var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil) var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} } func New(Options) *Validator { return &Validator{} }
@@ -35,7 +35,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}} return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
} }
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) { func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCOccurrenceList]) (contracts.ValidationResult, error) {
if interactionshape.Validate(req.Value) != nil { if interactionshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
@@ -43,23 +43,23 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if err != nil { if err != nil {
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
citedTexts := make([]string, len(req.Value.Interactions)) citedTexts := make([]string, len(req.Value.Occurrences))
for index, interaction := range req.Value.Interactions { for index, occurrence := range req.Value.Occurrences {
citedText, err := resolver.CitedText(interaction.SourceRefs) citedText, err := resolver.CitedText(occurrence.SourceRefs)
if err != nil { if err != nil {
return contracts.ValidationResult{Approved: true}, nil return contracts.ValidationResult{Approved: true}, nil
} }
citedTexts[index] = citedText citedTexts[index] = citedText
} }
warnings := make([]contracts.Warning, 0) warnings := make([]contracts.Warning, 0)
for index, interaction := range req.Value.Interactions { for index, occurrence := range req.Value.Occurrences {
if shared.ContainsTokenSequence(citedTexts[index], interaction.Name) { if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) {
continue continue
} }
warnings = append(warnings, contracts.Warning{ warnings = append(warnings, contracts.Warning{
Scope: fmt.Sprintf("interactions[%d]", index), Scope: fmt.Sprintf("occurrences[%d]", index),
ReasonCode: WarningReasonCode, ReasonCode: WarningReasonCode,
Message: fmt.Sprintf("NPC interaction name %s was not found in cited source text", diagnostics.Quote(interaction.Name)), Message: fmt.Sprintf("NPC interaction name %s was not found in cited source text", diagnostics.Quote(occurrence.Name)),
}) })
} }
return contracts.ValidationResult{ return contracts.ValidationResult{
@@ -73,7 +73,7 @@ func Spec() pipeline.ValidatorSpec {
} }
func Register(registry *pipeline.ValidatorRegistry) error { func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCInteractionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCInteractionList], error) { return pipeline.RegisterTypedValidatorBuilder(registry, dnd.NPCOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.NPCOccurrenceList], error) {
options, err := DecodeOptions(request.Options) options, err := DecodeOptions(request.Options)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -14,34 +14,34 @@ import (
) )
func TestValidatorUsesOnlyCurrentTranscriptAndWarnsOncePerInteraction(t *testing.T) { func TestValidatorUsesOnlyCurrentTranscriptAndWarnsOncePerInteraction(t *testing.T) {
value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{ value := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
{Name: "O'Rin Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}, {NPCID: "npc:test", Name: "O'Rin Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}},
{Name: "Missing\nNPC", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2}}}, {NPCID: "npc:test", Name: "Missing\nNPC", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}, {SourceID: "session", StartUnitID: 2, EndUnitID: 2}}},
}} }}
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "ORin Thorn speaks."}, {ID: 2, Text: "The party waits."}}} doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "ORin Thorn speaks."}, {ID: 2, Text: "The party waits."}}}
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Missing NPC")}}}}} references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Missing NPC")}}}}}
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: doc, References: references, Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: doc, References: references, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 1 { if err != nil || !result.Approved || len(result.Warnings) != 1 {
t.Fatalf("Validate() = %#v, %v", result, err) t.Fatalf("Validate() = %#v, %v", result, err)
} }
if warning := result.Warnings[0]; warning.Scope != "interactions[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nNPC`) { if warning := result.Warnings[0]; warning.Scope != "occurrences[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, `Missing\nNPC`) {
t.Fatalf("warning = %#v", warning) t.Fatalf("warning = %#v", warning)
} }
} }
func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) { func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
for _, value := range []dnd.NPCInteractionList{ for _, value := range []dnd.NPCOccurrenceList{
{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}}, {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn"}}},
{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}, {Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}},
} { } {
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}}}, Value: value}) result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}}}, Value: value})
if err != nil || !result.Approved || len(result.Warnings) != 0 { if err != nil || !result.Approved || len(result.Warnings) != 0 {
t.Fatalf("deferral = %#v, %v", result, err) t.Fatalf("deferral = %#v, %v", result, err)
} }
} }
value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}} value := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}}
before := value before := value
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Mira Thorn"}}}, Value: value}) _, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Mira Thorn"}}}, Value: value})
if err != nil || !reflect.DeepEqual(value, before) { if err != nil || !reflect.DeepEqual(value, before) {
t.Fatalf("Validate() mutated value: %#v", value) t.Fatalf("Validate() mutated value: %#v", value)
} }
@@ -53,17 +53,18 @@ func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
func TestValidatorBoundsWarnings(t *testing.T) { func TestValidatorBoundsWarnings(t *testing.T) {
count := diagnostics.MaxWarnings + 5 count := diagnostics.MaxWarnings + 5
interactions := make([]dnd.NPCInteraction, count) interactions := make([]dnd.NPCOccurrence, count)
for index := range interactions { for index := range interactions {
interactions[index] = dnd.NPCInteraction{ interactions[index] = dnd.NPCOccurrence{
NPCID: "npc:test",
Name: "Missing NPC", Name: "Missing NPC",
Kind: dnd.NPCInteractionKindMentioned, Kind: dnd.NPCOccurrenceKindMentioned,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
} }
} }
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCInteractionList]{ result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{
Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "The party waits."}}}, Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "The party waits."}}},
Value: dnd.NPCInteractionList{Interactions: interactions}, Value: dnd.NPCOccurrenceList{Occurrences: interactions},
}) })
if err != nil || !result.Approved { if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v", result, err) t.Fatalf("Validate() = %#v, %v", result, err)

View File

@@ -36,7 +36,7 @@ func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing
} }
request := client.requestFor(t, interactionextract.PromptID) request := client.requestFor(t, interactionextract.PromptID)
wantRegistry := `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` wantRegistry := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Hooded Guard") + `","name":"Hooded Guard"}]}`
if got := string(request.Inputs["npc_registry"].Content); got != wantRegistry { if got := string(request.Inputs["npc_registry"].Content); got != wantRegistry {
t.Fatalf("interaction registry input = %s, want names-only projection %s", got, wantRegistry) t.Fatalf("interaction registry input = %s, want names-only projection %s", got, wantRegistry)
} }
@@ -52,25 +52,25 @@ func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing
if err != nil { if err != nil {
t.Fatalf("Decode(interaction output) error = %v", err) t.Fatalf("Decode(interaction output) error = %v", err)
} }
if len(interactions.Interactions) != 2 { if len(interactions.Occurrences) != 2 {
t.Fatalf("interactions = %#v, want two occurrences", interactions) t.Fatalf("interactions = %#v, want two occurrences", interactions)
} }
first, second := interactions.Interactions[0], interactions.Interactions[1] first, second := interactions.Occurrences[0], interactions.Occurrences[1]
if first.Name != "Mira Thorn" || string(first.Kind) != "dialogue" || second.Name != "Hooded Guard" || string(second.Kind) != "noncombat_presence" { if first.Name != "Mira Thorn" || string(first.Kind) != "dialogue" || second.Name != "Hooded Guard" || string(second.Kind) != "noncombat_presence" {
t.Fatalf("interactions = %#v, want canonical names, kinds, and source chronology", interactions) t.Fatalf("interactions = %#v, want canonical names, kinds, and source chronology", interactions)
} }
assertInteractionEvidence(t, first.SourceRefs) assertInteractionEvidence(t, first.SourceRefs)
assertInteractionEvidence(t, second.SourceRefs) assertInteractionEvidence(t, second.SourceRefs)
if first.SourceRefs[0].StartUnitID >= second.SourceRefs[0].StartUnitID { if first.SourceRefs[0].StartUnitID >= second.SourceRefs[0].StartUnitID {
t.Fatalf("interaction chronology = %#v, want source order", interactions.Interactions) t.Fatalf("interaction chronology = %#v, want source order", interactions.Occurrences)
} }
var durable map[string]json.RawMessage var durable map[string]json.RawMessage
if err := json.Unmarshal(serialized.Artifact.Content, &durable); err != nil { if err := json.Unmarshal(serialized.Artifact.Content, &durable); err != nil {
t.Fatalf("unmarshal durable interaction payload: %v", err) t.Fatalf("unmarshal durable interaction payload: %v", err)
} }
if len(durable) != 1 || durable["interactions"] == nil { if len(durable) != 1 || durable["occurrences"] == nil {
t.Fatalf("durable interaction payload = %#v, want only interactions", durable) t.Fatalf("durable interaction payload = %#v, want only occurrences", durable)
} }
} }
@@ -113,8 +113,8 @@ func TestSemanticNPCNormalizationCrossesOrderedRegistryHandoff(t *testing.T) {
t.Fatalf("interaction output step = %q, want ordered downstream step", interactionOutput.StepID) t.Fatalf("interaction output step = %q, want ordered downstream step", interactionOutput.StepID)
} }
registryRequest := client.requestFor(t, interactionextract.PromptID) registryRequest := client.requestFor(t, interactionextract.PromptID)
if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"name":"Mira Thorn"}]}` { if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` {
t.Fatalf("downstream registry = %s, want one canonical names-only NPC", got) t.Fatalf("downstream registry = %s, want one canonical NPC identity", got)
} }
manifestContent, err := json.Marshal(output.Manifest) manifestContent, err := json.Marshal(output.Manifest)
if err != nil { if err != nil {
@@ -228,9 +228,9 @@ func (client *npcInteractionLLMClient) CompleteStructured(ctx context.Context, r
case npcnormalize.PromptID: case npcnormalize.PromptID:
payload = map[string]any{"duplicate_groups": []any{}} payload = map[string]any{"duplicate_groups": []any{}}
case interactionextract.PromptID: case interactionextract.PromptID:
payload = map[string]any{"interactions": []any{ payload = map[string]any{"occurrences": []any{
map[string]any{"name": "Hooded Guard", "kind": "noncombat_presence", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}}, map[string]any{"npc_id": identity.DeriveID("Hooded Guard"), "name": "Hooded Guard", "kind": "noncombat_presence", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}},
map[string]any{"name": " mira thorn ", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 2, "end_unit_id": 2}}}, map[string]any{"npc_id": identity.DeriveID("Mira Thorn"), "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 2, "end_unit_id": 2}}},
}} }}
default: default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected interaction prompt %q", request.PromptID) return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected interaction prompt %q", request.PromptID)
@@ -291,7 +291,7 @@ func (client *semanticNPCInteractionClient) CompleteStructured(_ context.Context
case npcnormalize.PromptID: case npcnormalize.PromptID:
payload = map[string]any{"duplicate_groups": []any{map[string]any{"members": []string{"candidate-000001", "candidate-000002"}, "canonical": "candidate-000001"}}} payload = map[string]any{"duplicate_groups": []any{map[string]any{"members": []string{"candidate-000001", "candidate-000002"}, "canonical": "candidate-000001"}}}
case interactionextract.PromptID: case interactionextract.PromptID:
payload = map[string]any{"interactions": []any{map[string]any{"name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}} payload = map[string]any{"occurrences": []any{map[string]any{"npc_id": identity.DeriveID("Mira Thorn"), "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}}
default: default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected semantic pipeline prompt %q", request.PromptID) return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected semantic pipeline prompt %q", request.PromptID)
} }