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
transcript. Include an occurrence only when the transcript establishes one
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,
or persistent state. Do not identify player characters, anonymous groups, or

View File

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

View File

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

View File

@@ -9,22 +9,23 @@ or summarize the session.
| Property | Value |
| --- | --- |
| Artifact kind | `dnd/npc-interaction-list` |
| Schema ID | `notarius.dnd.npc_interactions` |
| Schema name | `notarius_dnd_npc_interactions_v1` |
| Artifact kind | `dnd/npc-occurrence-list` |
| Schema ID | `notarius.dnd.npc_occurrences` |
| Schema name | `notarius_dnd_npc_occurrences_v1` |
| Schema version | `v1` |
| Media type | `application/json` |
`v1` is a strict JSON object with required `interactions`; the array may be
empty. Interaction and source-reference objects reject unknown fields. An
`v1` is a strict JSON object with required `occurrences`; the array may be
empty. Occurrence and source-reference objects reject unknown fields. An
incompatible shape change requires a new schema version.
## Wire shape
Each interaction has these required fields:
Each occurrence has these required fields:
| Field | Contract |
| --- | --- |
| `npc_id` | Exact durable ID from the required NPC registry. |
| `name` | Non-empty canonical display name from the required NPC registry. |
| `kind` | One of the interaction categories below. |
| `source_refs` | One or more transcript evidence ranges. |
@@ -36,8 +37,9 @@ for an interaction is confined to its accepted chunk.
```json
{
"interactions": [
"occurrences": [
{
"npc_id": "npc:sha256:example",
"name": "Mira Thorn",
"kind": "dialogue",
"source_refs": [
@@ -65,12 +67,14 @@ for uncertain classification.
## Identity, evidence, and order
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves `name`.
Registry references are provenance only and never replace an interaction's own
evidence. Normalization canonicalizes recognized registry names, orders and
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves
the exact `{npc_id, name}` pair. Unknown IDs and names that do not match their
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
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.
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}]}]}`)
case npcinteractions.PromptID:
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 {
content = []byte(`{"interactions":[]}`)
content = []byte(`{"occurrences":[]}`)
}
case locationoccurrences.PromptID:
var registry struct {

View File

@@ -25,7 +25,7 @@ func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) {
t.Fatalf("resolved pipeline = %#v", resolved)
}
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)
}
for _, bindings := range [][]pipeline.ReferenceBinding{lane.ExtractReferences.Bindings, lane.NormalizeReferences.Bindings} {
@@ -38,7 +38,7 @@ func TestProductionNPCInteractionPipelineResolvesAndPrepares(t *testing.T) {
}
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 {
t.Fatalf("NPC interaction codec spec = %#v", codecSpec)
}

View File

@@ -1,17 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "notarius.dnd.npc_interactions",
"$id": "notarius.dnd.npc_occurrences",
"type": "object",
"additionalProperties": false,
"required": ["interactions"],
"required": ["occurrences"],
"properties": {
"interactions": {
"occurrences": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["name", "kind", "source_refs"],
"required": ["npc_id", "name", "kind", "source_refs"],
"properties": {
"npc_id": {
"type": "string",
"minLength": 1
},
"name": {
"type": "string",
"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
import (
@@ -12,25 +12,25 @@ import (
)
const (
SchemaID = "notarius.dnd.npc_interactions"
SchemaName = "notarius_dnd_npc_interactions_v1"
SchemaID = "notarius.dnd.npc_occurrences"
SchemaName = "notarius_dnd_npc_occurrences_v1"
SchemaVersion = "v1"
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 _ contracts.ArtifactCodec[dnd.NPCInteractionList] = (*Codec)(nil)
var _ contracts.ArtifactCodec[dnd.NPCOccurrenceList] = (*Codec)(nil)
type Codec struct{}
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 {
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 {
return contracts.ArtifactSchema{}
}
@@ -44,56 +44,59 @@ func (c *Codec) Schema() contracts.ArtifactSchema {
func (c *Codec) MediaType() string { return MediaType }
func (c *Codec) Metadata(value dnd.NPCInteractionList) map[string]any {
return map[string]any{"interaction_count": len(value.Interactions)}
func (c *Codec) Metadata(value dnd.NPCOccurrenceList) map[string]any {
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 {
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)
}
// EncodeCandidate provides the durable representation before semantic
// validators have approved a value.
func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) {
return candidatejson.EncodeCandidate("dnd npc interaction list", value)
func (c *Codec) EncodeCandidate(value dnd.NPCOccurrenceList) ([]byte, error) {
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)
if err != nil {
return dnd.NPCInteractionList{}, err
return dnd.NPCOccurrenceList{}, err
}
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
}
// DecodeCandidate reads one strict durable JSON value before semantic
// validators have approved it.
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCInteractionList, error) {
return candidatejson.DecodeCandidate[dnd.NPCInteractionList]("dnd npc interaction list", content)
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCOccurrenceList, error) {
return candidatejson.DecodeCandidate[dnd.NPCOccurrenceList]("dnd npc occurrence list", content)
}
func validate(value dnd.NPCInteractionList) error {
if value.Interactions == nil {
return fmt.Errorf("interactions must be present")
func validate(value dnd.NPCOccurrenceList) error {
if value.Occurrences == nil {
return fmt.Errorf("occurrences must be present")
}
for index, interaction := range value.Interactions {
prefix := fmt.Sprintf("interactions[%d]", index)
if strings.TrimSpace(interaction.Name) == "" {
for index, occurrence := range value.Occurrences {
prefix := fmt.Sprintf("occurrences[%d]", index)
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)
}
if !validInteractionKind(interaction.Kind) {
if !validOccurrenceKind(occurrence.Kind) {
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)
}
for refIndex, ref := range interaction.SourceRefs {
for refIndex, ref := range occurrence.SourceRefs {
refPrefix := fmt.Sprintf("%s.source_refs[%d]", prefix, refIndex)
if strings.TrimSpace(ref.SourceID) == "" {
return fmt.Errorf("%s.source_id must not be empty", refPrefix)
@@ -109,14 +112,14 @@ func validate(value dnd.NPCInteractionList) error {
return nil
}
func validInteractionKind(value dnd.NPCInteractionKind) bool {
func validOccurrenceKind(value dnd.NPCOccurrenceKind) bool {
switch value {
case dnd.NPCInteractionKindMentioned,
dnd.NPCInteractionKindNoncombatPresence,
dnd.NPCInteractionKindDialogue,
dnd.NPCInteractionKindCombatAlly,
dnd.NPCInteractionKindCombatOpponent,
dnd.NPCInteractionKindOther:
case dnd.NPCOccurrenceKindMentioned,
dnd.NPCOccurrenceKindNoncombatPresence,
dnd.NPCOccurrenceKindDialogue,
dnd.NPCOccurrenceKindCombatAlly,
dnd.NPCOccurrenceKindCombatOpponent,
dnd.NPCOccurrenceKindOther:
return true
default:
return false

View File

@@ -15,21 +15,21 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
)
func validList() dnd.NPCInteractionList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
func validList() dnd.NPCOccurrenceList {
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}},
},
{
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}},
},
}}
}
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 {
t.Fatal(err)
}
@@ -57,7 +57,7 @@ func TestCodecMatchesMaintainedDurableFixture(t *testing.T) {
func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
codec := New()
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())
}
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 {
t.Fatal(err)
}
spec, ok := registry.Spec(dnd.NPCInteractionListKind)
spec, ok := registry.Spec(dnd.NPCOccurrenceListKind)
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
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")
} else {
var typeErr *pipeline.ArtifactCodecTypeError
@@ -83,17 +83,17 @@ func TestCodecOwnsDurableSchemaAndRegistersExactType(t *testing.T) {
func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *testing.T) {
codec := New()
empty := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{}}
empty := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{}}
content, err := codec.Encode(empty)
if err != nil || string(content) != `{"interactions":[]}` {
if err != nil || string(content) != `{"occurrences":[]}` {
t.Fatalf("Encode() = %s, %v", content, err)
}
for _, candidate := range []dnd.NPCInteractionList{
for _, candidate := range []dnd.NPCOccurrenceList{
{},
empty,
{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: "unsupported", SourceRefs: nil}}},
{Interactions: []dnd.NPCInteraction{{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: nil}}},
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{}}}},
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: " ", Kind: "unsupported", SourceRefs: []source.SourceRef{{StartUnitID: 0, EndUnitID: -1}}}}},
} {
content, err := codec.EncodeCandidate(candidate)
if err != nil || !json.Valid(content) {
@@ -107,13 +107,13 @@ func TestCodecSupportsEmptyListAndPreservesCollectionPresenceInCandidates(t *tes
}
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 }{
{"malformed", `{`, "decode dnd npc interaction list"},
{"unknown top-level", `{"interactions":[],"unexpected":true}`, "unknown field"},
{"malformed", `{`, "decode dnd npc occurrence list"},
{"unknown top-level", `{"occurrences":[],"unexpected":true}`, "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"},
{"trailing", `{"interactions":[]} {}`, "multiple JSON values"},
{"trailing", `{"occurrences":[]} {}`, "multiple JSON values"},
} {
t.Run(test.name, func(t *testing.T) {
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) {
tests := []struct {
name string
value dnd.NPCInteractionList
value dnd.NPCOccurrenceList
want string
}{
{"nil interactions", dnd.NPCInteractionList{}, "interactions must be present"},
{"blank name", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Name = " " }), "name must not be empty"},
{"unsupported kind", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].Kind = "unsupported" }), "kind must be supported"},
{"nil source refs", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs = nil }), "source_refs must contain"},
{"empty source ID", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
{"non-positive start", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].StartUnitID = 0 }), "start_unit_id must be positive"},
{"non-positive end", mutate(validList(), func(v *dnd.NPCInteractionList) { v.Interactions[0].SourceRefs[0].EndUnitID = 0 }), "end_unit_id must be positive"},
{"nil occurrences", dnd.NPCOccurrenceList{}, "occurrences must be present"},
{"blank NPC ID", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].NPCID = " " }), "npc_id must not be empty"},
{"blank name", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].Name = " " }), "name must not be empty"},
{"unsupported kind", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].Kind = "unsupported" }), "kind must be supported"},
{"nil source refs", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs = nil }), "source_refs must contain"},
{"empty source ID", mutate(validList(), func(v *dnd.NPCOccurrenceList) { v.Occurrences[0].SourceRefs[0].SourceID = " " }), "source_id must not be empty"},
{"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 {
t.Run(test.name, func(t *testing.T) {
@@ -147,16 +148,16 @@ func TestCodecRejectsRequiredShapeEnumAndReferenceBoundaries(t *testing.T) {
}
func TestCodecAcceptsEveryInteractionKind(t *testing.T) {
for _, kind := range []dnd.NPCInteractionKind{
dnd.NPCInteractionKindMentioned,
dnd.NPCInteractionKindNoncombatPresence,
dnd.NPCInteractionKindDialogue,
dnd.NPCInteractionKindCombatAlly,
dnd.NPCInteractionKindCombatOpponent,
dnd.NPCInteractionKindOther,
for _, kind := range []dnd.NPCOccurrenceKind{
dnd.NPCOccurrenceKindMentioned,
dnd.NPCOccurrenceKindNoncombatPresence,
dnd.NPCOccurrenceKindDialogue,
dnd.NPCOccurrenceKindCombatAlly,
dnd.NPCOccurrenceKindCombatOpponent,
dnd.NPCOccurrenceKindOther,
} {
value := validList()
value.Interactions[0].Kind = kind
value.Occurrences[0].Kind = kind
if _, err := New().Encode(value); err != nil {
t.Fatalf("Encode(%q) error = %v", kind, err)
}
@@ -172,12 +173,12 @@ func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) {
}
metadata := codec.Metadata(validList())
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)
}
}
func mutate(value dnd.NPCInteractionList, change func(*dnd.NPCInteractionList)) dnd.NPCInteractionList {
func mutate(value dnd.NPCOccurrenceList, change func(*dnd.NPCOccurrenceList)) dnd.NPCOccurrenceList {
change(&value)
return value
}

View File

@@ -1,6 +1,7 @@
{
"interactions": [
"occurrences": [
{
"npc_id": "npc:test-mira",
"name": "Mira Thorn",
"kind": "dialogue",
"source_refs": [
@@ -8,6 +9,7 @@
]
},
{
"npc_id": "npc:test-guard",
"name": "Hooded Guard",
"kind": "combat_opponent",
"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.",
Required: true,
AcceptedMediaTypes: []string{interactioncodec.MediaType},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCInteractionListKind},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCOccurrenceListKind},
MaxBytes: ReferenceMaxBytes,
},
)
@@ -223,7 +223,7 @@ func prepareNPCInteractionInput(references contracts.ReferenceSet) (*contracts.L
}
content, err := json.Marshal(struct {
Interactions []npcInteractionProjection `json:"npc_interactions"`
}{Interactions: projectNPCInteractions(value.Interactions)})
}{Interactions: projectNPCInteractions(value.Occurrences)})
if err != nil {
return nil, fmt.Errorf("encode NPC-interaction grounding: %w", err)
}
@@ -272,15 +272,15 @@ func projectCombatTurns(turns []dnd.CombatTurn) []combatTurnProjection {
}
type npcInteractionProjection struct {
Name string `json:"name"`
Kind dnd.NPCInteractionKind `json:"kind"`
Name string `json:"name"`
Kind dnd.NPCOccurrenceKind `json:"kind"`
}
func projectNPCInteractions(interactions []dnd.NPCInteraction) []npcInteractionProjection {
projection := make([]npcInteractionProjection, 0, len(interactions))
for _, interaction := range interactions {
if interaction.Kind == dnd.NPCInteractionKindCombatOpponent {
projection = append(projection, npcInteractionProjection{Name: interaction.Name, Kind: interaction.Kind})
func projectNPCInteractions(occurrences []dnd.NPCOccurrence) []npcInteractionProjection {
projection := make([]npcInteractionProjection, 0, len(occurrences))
for _, occurrence := range occurrences {
if occurrence.Kind == dnd.NPCOccurrenceKindCombatOpponent {
projection = append(projection, npcInteractionProjection{Name: occurrence.Name, Kind: occurrence.Kind})
}
}
return projection

View File

@@ -36,7 +36,7 @@ func TestReferenceSlotsDescribeRequiredTypedArtifacts(t *testing.T) {
{NPCRegistryReferenceSlot, dnd.NPCRegistryKind},
{SceneDescriptionReferenceSlot, dnd.SceneDescriptionListKind},
{CombatTurnReferenceSlot, dnd.CombatTurnListKind},
{NPCInteractionReferenceSlot, dnd.NPCInteractionListKind},
{NPCInteractionReferenceSlot, dnd.NPCOccurrenceListKind},
} {
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 {
@@ -220,9 +220,9 @@ func groundingReferences(t *testing.T, enemy string, sceneKind dnd.SceneKind) co
if err != nil {
t.Fatal(err)
}
interactionContent, err := interactioncodec.New().Encode(dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
{Name: enemy, Kind: dnd.NPCInteractionKindCombatOpponent, 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}}},
interactionContent, err := interactioncodec.New().Encode(dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
{NPCID: identity.DeriveID(enemy), Name: enemy, Kind: dnd.NPCOccurrenceKindCombatOpponent, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 1, EndUnitID: 1}}},
{NPCID: identity.DeriveID("Aria"), Name: "Aria", Kind: dnd.NPCOccurrenceKindCombatAlly, SourceRefs: []source.SourceRef{{SourceID: "combat-session", StartUnitID: 2, EndUnitID: 2}}},
}})
if err != nil {
t.Fatal(err)

View File

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

View File

@@ -52,7 +52,7 @@ func referenceSlots() []contracts.ReferenceSlot {
return slots
}
var _ contracts.Extractor[dnd.NPCInteractionList] = (*Extractor)(nil)
var _ contracts.Extractor[dnd.NPCOccurrenceList] = (*Extractor)(nil)
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
@@ -132,33 +132,33 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA},
{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 {
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 {
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)
if err != nil {
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("%w", err)
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("%w", err)
}
order := shared.NewSourceRefOrder(req.Source)
npcRegistry, err := e.npcResolver.Resolve(req.References)
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() {
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
inputs := shared.PromptInputs(sourceInput, req.References)
inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput()
inputs[NPCRegistryReferenceSlot] = npcRegistry.IdentityPromptInput()
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key,
PromptID: PromptID,
@@ -167,10 +167,27 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
SessionID: req.SessionID,
Inputs: inputs,
}, &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)
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 {
@@ -180,13 +197,13 @@ func ModuleSpec() pipeline.ModuleSpec {
ExecutionClass: contracts.ExecutionClassLLMBacked,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.NPCInteractionListKind,
ArtifactKind: dnd.NPCOccurrenceListKind,
ReferenceSlots: referenceSlots(),
}
}
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)
if err != nil {
return nil, err

View File

@@ -17,14 +17,14 @@ import (
)
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{
{Name: "Other", Kind: "other", SourceRefs: interactionRefs(30, 30)},
{Name: "Opponent", Kind: "combat_opponent", SourceRefs: interactionRefs(20, 20)},
{Name: "Ally", Kind: "combat_ally", SourceRefs: interactionRefs(5, 5)},
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(interactionRefs(2, 2), interactionRefs(2, 2)...)},
{Name: "Present", Kind: "noncombat_presence", SourceRefs: interactionRefs(7, 7)},
{Name: "Mentioned", Kind: "mentioned", SourceRefs: interactionRefs(10, 10)},
{Name: "Invalid", Kind: "unsupported", SourceRefs: interactionRefs(0, 0)},
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{NPCID: identity.DeriveID("Other"), Name: "Other", Kind: "other", SourceRefs: interactionRefs(30, 30)},
{NPCID: identity.DeriveID("Opponent"), Name: "Opponent", Kind: "combat_opponent", SourceRefs: interactionRefs(20, 20)},
{NPCID: identity.DeriveID("Ally"), Name: "Ally", Kind: "combat_ally", SourceRefs: interactionRefs(5, 5)},
{NPCID: identity.DeriveID("Speaker"), Name: "Speaker", Kind: "dialogue", SourceRefs: append(interactionRefs(2, 2), interactionRefs(2, 2)...)},
{NPCID: identity.DeriveID("Present"), Name: "Present", Kind: "noncombat_presence", SourceRefs: interactionRefs(7, 7)},
{NPCID: identity.DeriveID("Mentioned"), Name: "Mentioned", Kind: "mentioned", SourceRefs: interactionRefs(10, 10)},
{NPCID: identity.DeriveID("Invalid"), Name: "Invalid", Kind: "unsupported", SourceRefs: interactionRefs(0, 0)},
}}}
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
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"}) {
t.Fatalf("interaction order = %#v", got)
}
if got := interactionKinds(result.Value); !reflect.DeepEqual(got, []dnd.NPCInteractionKind{
dnd.NPCInteractionKindMentioned,
dnd.NPCInteractionKindDialogue,
dnd.NPCInteractionKindNoncombatPresence,
dnd.NPCInteractionKindCombatAlly,
dnd.NPCInteractionKindCombatOpponent,
dnd.NPCInteractionKindOther,
if got := interactionKinds(result.Value); !reflect.DeepEqual(got, []dnd.NPCOccurrenceKind{
dnd.NPCOccurrenceKindMentioned,
dnd.NPCOccurrenceKindDialogue,
dnd.NPCOccurrenceKindNoncombatPresence,
dnd.NPCOccurrenceKindCombatAlly,
dnd.NPCOccurrenceKindCombatOpponent,
dnd.NPCOccurrenceKindOther,
"unsupported",
}) {
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)
}
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)
}
if len(client.requests) != 1 {
@@ -60,15 +60,15 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
}
func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{
{Name: "Later", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)},
{Name: "First", Kind: "mentioned", SourceRefs: []interactionSourceRefResponse{
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{NPCID: identity.DeriveID("Later"), Name: "Later", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)},
{NPCID: identity.DeriveID("First"), Name: "First", Kind: "mentioned", SourceRefs: []interactionSourceRefResponse{
{StartUnitID: 10, EndUnitID: 10},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 30, EndUnitID: 30},
{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")
req := extractionRequest()
@@ -84,12 +84,12 @@ func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) {
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)
}
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}) {
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
}
refs[0].StartUnitID = 777
for _, interaction := range client.response.Interactions {
for _, interaction := range client.response.Occurrences {
for _, ref := range interaction.SourceRefs {
if ref.StartUnitID == 777 {
t.Fatal("result source references alias the model response")
@@ -107,9 +107,9 @@ func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
}
}
func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{{
Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10),
func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
NPCID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Kind: "dialogue", SourceRefs: interactionRefs(10, 10),
}}}}
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
req := extractionRequest()
@@ -119,10 +119,10 @@ func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T)
}
request := client.requests[0]
registry := request.Inputs[NPCRegistryReferenceSlot]
if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` {
t.Fatalf("registry prompt input = %#v, want exact names-only projection", registry)
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 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) {
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) {
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{}}}
client := &fakeInteractionsLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{}}}
references := requiredRegistryReferences(t, "Mira Thorn")
req := extractionRequest()
req.References = references
@@ -171,7 +192,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
if _, err := extractor.Extract(context.Background(), req); err != nil {
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)
}
metadata := extractor.ManifestMetadata()
@@ -191,11 +212,11 @@ func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) {
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.References = references
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)
}
}
@@ -230,7 +251,7 @@ func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
func TestModuleSpecRegistrationMetadataAndFingerprints(t *testing.T) {
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)
}
var registrySlot contracts.ReferenceSlot
@@ -342,17 +363,17 @@ func interactionRefs(start, end int) []interactionSourceRefResponse {
return []interactionSourceRefResponse{{StartUnitID: start, EndUnitID: end}}
}
func interactionNames(value dnd.NPCInteractionList) []string {
names := make([]string, len(value.Interactions))
for index, interaction := range value.Interactions {
func interactionNames(value dnd.NPCOccurrenceList) []string {
names := make([]string, len(value.Occurrences))
for index, interaction := range value.Occurrences {
names[index] = interaction.Name
}
return names
}
func interactionKinds(value dnd.NPCInteractionList) []dnd.NPCInteractionKind {
kinds := make([]dnd.NPCInteractionKind, len(value.Interactions))
for index, interaction := range value.Interactions {
func interactionKinds(value dnd.NPCOccurrenceList) []dnd.NPCOccurrenceKind {
kinds := make([]dnd.NPCOccurrenceKind, len(value.Occurrences))
for index, interaction := range value.Occurrences {
kinds[index] = interaction.Kind
}
return kinds

View File

@@ -1,10 +1,11 @@
package npcinteractions
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"`
Kind string `json:"kind"`
SourceRefs []interactionSourceRefResponse `json:"source_refs"`

View File

@@ -6,13 +6,13 @@ import (
)
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
if err := json.Unmarshal(content, &response); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
interaction := response.Interactions[0]
if interaction.Name != "" || interaction.Kind != "unsupported" || interaction.SourceRefs[0] != (interactionSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
t.Fatalf("decoded response = %#v", interaction)
occurrence := response.Occurrences[0]
if occurrence.NPCID != "" || occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (interactionSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
t.Fatalf("decoded response = %#v", occurrence)
}
}

View File

@@ -20,7 +20,7 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
if err != nil {
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)
}
options, err := registry.PromptKitOptions()
@@ -42,13 +42,13 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
"players": promptkit.Inline("interaction-player"),
"party": promptkit.Inline("Mira: ranger"),
"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 {
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)
}
}
@@ -59,7 +59,7 @@ func TestPromptMetadataDoesNotExposeAssetContent(t *testing.T) {
t.Fatalf("promptAssetMetadata() = %q, %v", hash, err)
}
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) {
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 (
PromptID = "dnd.npc_interactions"
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_npc_interactions_llm")
ResponseSchemaID = "notarius.dnd.npc_interactions.llm"
ResponseSchemaName = "notarius_dnd_npc_interactions_llm_v1"
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_npc_occurrences_llm")
ResponseSchemaID = "notarius.dnd.npc_occurrences.llm"
ResponseSchemaName = "notarius_dnd_npc_occurrences_llm_v1"
SchemaVersion = "v1"
)
@@ -20,6 +20,6 @@ func loadResponseSchema() (llm.ResponseSchema, error) {
ID: ResponseSchemaID,
Version: SchemaVersion,
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()
interaction := semanticCandidate["interactions"].([]any)[0].(map[string]any)
interaction := semanticCandidate["occurrences"].([]any)[0].(map[string]any)
interaction["name"] = ""
interaction["kind"] = "unsupported"
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){
func(record map[string]any) { delete(record, "npc_id") },
func(record map[string]any) { delete(record, "name") },
func(record map[string]any) { record["kind"] = 1 },
func(record map[string]any) { record["unexpected"] = true },
@@ -50,7 +51,7 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
},
} {
candidate := validInteractionResponse()
mutate(candidate["interactions"].([]any)[0].(map[string]any))
mutate(candidate["occurrences"].([]any)[0].(map[string]any))
content, err := json.Marshal(candidate)
if err != nil {
t.Fatal(err)
@@ -79,8 +80,8 @@ func TestResponseSchemaIsDefensiveAndContentSafe(t *testing.T) {
}
func validInteractionResponse() map[string]any {
return map[string]any{"interactions": []any{map[string]any{
"name": "Mira Thorn", "kind": "dialogue",
return map[string]any{"occurrences": []any{map[string]any{
"npc_id": "npc:sha256:test", "name": "Mira Thorn", "kind": "dialogue",
"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/modules/dnd"
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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
@@ -22,7 +21,6 @@ const (
normalizationPolicy = "dnd.npc_interactions.normalize.v2"
NormalizationPolicy = normalizationPolicy
ReasonCodeNameCanonicalized = "npc_interaction_name_canonicalized"
ReasonCodeSourceRefsNormalized = "source_references_normalized"
ReasonCodeInteractionsReordered = "npc_interactions_reordered"
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.",
}
var _ contracts.Normalizer[dnd.NPCInteractionList] = (*Normalizer)(nil)
var _ contracts.Normalizer[dnd.NPCOccurrenceList] = (*Normalizer)(nil)
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
@@ -79,7 +77,6 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
}
metadata := map[string]any{
"normalization_policy": normalizationPolicy,
"identity_policy": identity.Policy,
}
seeded := n.npcResolver.Seeded()
if seeded.Bound() {
@@ -95,82 +92,74 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
return []pipeline.CheckpointFingerprint{
{Name: "normalization_policy", Value: normalizationPolicy},
{Name: "identity_policy", Value: identity.Policy},
{Name: "npc_registry", Value: n.npcResolver.Seeded().ProjectionDigest()},
{Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityPromptInput().Digest},
}
}
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 {
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 {
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 {
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)
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() {
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)
order := shared.NewSourceRefOrderFromIndex(index)
value, warnings := normalizeList(req.MergeOutput.Value, index, order, registry)
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil
value, warnings, err := normalizeList(req.MergeOutput.Value, index, order, registry)
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 {
interaction dnd.NPCInteraction
inputIndex int
occurrence dnd.NPCOccurrence
inputIndex int
}
type nameCanonicalization struct {
from string
to string
}
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
func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrenceList, []contracts.Warning, error) {
if input.Occurrences == nil {
return dnd.NPCOccurrenceList{}, nil, nil
}
records := make([]normalizedRecord, len(input.Interactions))
records := make([]normalizedRecord, len(input.Occurrences))
warnings := make([]contracts.Warning, 0)
for index, inputInteraction := range input.Interactions {
interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, order, registry)
records[index] = normalizedRecord{interaction: interaction, inputIndex: index}
if nameChange != nil {
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)),
})
for index, inputOccurrence := range input.Occurrences {
occurrence, refsChanged, err := normalizeOccurrence(inputOccurrence, order, registry)
if err != nil {
return dnd.NPCOccurrenceList{}, nil, fmt.Errorf("occurrences[%d]: %w", index, err)
}
records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index}
if refsChanged {
warnings = append(warnings, contracts.Warning{
Scope: interactionScope(index),
Scope: occurrenceScope(index),
ReasonCode: ReasonCodeSourceRefsNormalized,
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 {
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 {
if position == record.inputIndex {
continue
}
warnings = append(warnings, contracts.Warning{
Scope: interactionScope(record.inputIndex),
Scope: occurrenceScope(record.inputIndex),
ReasonCode: ReasonCodeInteractionsReordered,
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)
warnings = append(warnings, duplicateWarnings...)
return dnd.NPCInteractionList{Interactions: output},
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted)
return dnd.NPCOccurrenceList{Occurrences: output},
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted), nil
}
func normalizeInteraction(input dnd.NPCInteraction, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) {
output := cloneInteraction(input)
if canonical, ok := registry.Lookup(identity.NormalizeDisplay(input.Name)); ok {
output.Name = canonical.Name
func normalizeOccurrence(input dnd.NPCOccurrence, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrence, bool, error) {
canonical, ok := registry.LookupID(input.NPCID)
if !ok {
return dnd.NPCOccurrence{}, false, fmt.Errorf("npc_id is not in the NPC registry")
}
var nameChange *nameCanonicalization
if input.Name != output.Name {
nameChange = &nameCanonicalization{from: input.Name, to: output.Name}
if input.Name != canonical.Name {
return dnd.NPCOccurrence{}, false, fmt.Errorf("name does not match npc_id")
}
output := cloneOccurrence(input)
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
if input.SourceRefs != nil {
output.SourceRefs = append([]source.SourceRef(nil), input.SourceRefs...)
@@ -208,19 +197,19 @@ type duplicateGroup struct {
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 {
return make([]dnd.NPCInteraction, 0), nil
return make([]dnd.NPCOccurrence, 0), nil
}
keep := make([]bool, len(records))
groups := make([]duplicateGroup, 0)
groupByKey := make(map[string]int)
for index, record := range records {
if !interactionmodel.ValidSourceRefs(documentIndex, record.interaction.SourceRefs) {
if !interactionmodel.ValidSourceRefs(documentIndex, record.occurrence.SourceRefs) {
keep[index] = true
continue
}
key := interactionmodel.ExactIdentity(record.interaction)
key := interactionmodel.ExactIdentity(record.occurrence)
groupIndex, exists := groupByKey[key]
if !exists {
groupByKey[key] = len(groups)
@@ -230,10 +219,10 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen
}
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 {
if keep[index] {
output = append(output, cloneInteraction(record.interaction))
output = append(output, cloneOccurrence(record.occurrence))
}
}
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)
}
return contracts.Warning{
Scope: interactionScope(retainedIndex),
Scope: occurrenceScope(retainedIndex),
ReasonCode: ReasonCodeDuplicateCollapsed,
Message: diagnostics.Aggregate(
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 {
slots := shared.ReferenceSlots(referenceSlotDescriptions)
@@ -281,13 +270,13 @@ func ModuleSpec() pipeline.ModuleSpec {
ExecutionClass: contracts.ExecutionClassDeterministic,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ArtifactKind: dnd.NPCInteractionListKind,
ArtifactKind: dnd.NPCOccurrenceListKind,
ReferenceSlots: referenceSlots(),
}
}
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)
if err != nil {
return nil, err

View File

@@ -14,37 +14,37 @@ import (
"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}}}
normalizer, err := New(Options{}, npcReferences(t))
if err != nil {
t.Fatalf("New() error = %v", err)
}
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
Name: " áRIA ", Kind: dnd.NPCInteractionKindDialogue,
input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
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}},
}}}
original := append([]source.SourceRef(nil), input.Interactions[0].SourceRefs...)
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}})
original := append([]source.SourceRef(nil), input.Occurrences[0].SourceRefs...)
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: input}})
if err != nil {
t.Fatalf("Normalize() error = %v", err)
}
got := result.Value.Interactions[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}}) {
got := result.Value.Occurrences[0]
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)
}
if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
if !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
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)
}
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 {
t.Fatalf("second normalization = %#v, %v; want idempotent output without warnings", second, err)
}
result.Value.Interactions[0].SourceRefs[0].StartUnitID = 999
if input.Interactions[0].SourceRefs[0].StartUnitID == 999 {
result.Value.Occurrences[0].SourceRefs[0].StartUnitID = 999
if input.Occurrences[0].SourceRefs[0].StartUnitID == 999 {
t.Fatal("normalized source refs share input storage")
}
}
@@ -54,14 +54,14 @@ func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *te
if err != nil {
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")
}
for _, input := range []dnd.NPCInteractionList{{}, {Interactions: []dnd.NPCInteraction{}}} {
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{
MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}, References: npcReferences(t),
for _, input := range []dnd.NPCOccurrenceList{{}, {Occurrences: []dnd.NPCOccurrence{}}} {
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{
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)
}
}
@@ -70,16 +70,20 @@ func TestNormalizeRequiresOperationRegistryAndPreservesEmptyRepresentation(t *te
}
}
func TestNormalizeLeavesUnrecognizedNamesUntouched(t *testing.T) {
func TestNormalizeRejectsUnknownIDsAndMismatchedNames(t *testing.T) {
doc := testDocument()
normalizer, err := New(Options{}, npcReferences(t))
if err != nil {
t.Fatal(err)
}
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{interaction(" Unknown NPC ", dnd.NPCInteractionKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10})}}
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCInteractionList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCInteractionList]{Value: input}})
if err != nil || result.Value.Interactions[0].Name != input.Interactions[0].Name || hasWarning(result.Warnings, ReasonCodeNameCanonicalized) {
t.Fatalf("Normalize() = %#v, %v; want untouched unrecognized name", result, err)
for _, occurrence := range []dnd.NPCOccurrence{
interaction("Unknown NPC", dnd.NPCOccurrenceKindOther, source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}),
{NPCID: identity.DeriveID("Ária"), Name: "Borin", Kind: dnd.NPCOccurrenceKindOther, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
} {
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 {
return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit}
}
first := interaction("Ária", dnd.NPCInteractionKindDialogue, ref(50))
input := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
interaction("Borin", dnd.NPCInteractionKindMentioned, ref(90)),
first := interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(50))
input := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
interaction("Borin", dnd.NPCOccurrenceKindMentioned, ref(90)),
first,
first,
interaction("Ária", dnd.NPCInteractionKindCombatAlly, ref(50)),
interaction("Ária", dnd.NPCInteractionKindDialogue, ref(10)),
interaction("Ária", dnd.NPCInteractionKindDialogue, ref(999)),
interaction("Ária", dnd.NPCOccurrenceKindCombatAlly, ref(50)),
interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(10)),
interaction("Ária", dnd.NPCOccurrenceKindDialogue, ref(999)),
}}
normalizer, err := New(Options{}, npcReferences(t))
if err != nil {
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 {
t.Fatal(err)
}
got := result.Value.Interactions
got := result.Value.Occurrences
if len(got) != 5 {
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)
}
if !hasWarning(result.Warnings, ReasonCodeInteractionsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) {
@@ -122,13 +126,13 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) {
if err != nil {
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)
}
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)
}
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)
}
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
@@ -139,13 +143,13 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) {
func TestNormalizeBoundsWarnings(t *testing.T) {
count := diagnostics.MaxWarnings + 5
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 {
doc.Units[index].ID = index + 1
unitID := count - index
input.Interactions[index] = interaction(
input.Occurrences[index] = interaction(
"Ária",
dnd.NPCInteractionKindDialogue,
dnd.NPCOccurrenceKindDialogue,
source.SourceRef{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID},
)
}
@@ -153,8 +157,8 @@ func TestNormalizeBoundsWarnings(t *testing.T) {
if err != nil {
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 {
t.Fatal(err)
@@ -165,8 +169,8 @@ func TestNormalizeBoundsWarnings(t *testing.T) {
}
}
func interaction(name string, kind dnd.NPCInteractionKind, ref source.SourceRef) dnd.NPCInteraction {
return dnd.NPCInteraction{Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}}
func interaction(name string, kind dnd.NPCOccurrenceKind, ref source.SourceRef) dnd.NPCOccurrence {
return dnd.NPCOccurrence{NPCID: identity.DeriveID(name), Name: name, Kind: kind, SourceRefs: []source.SourceRef{ref}}
}
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/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
@@ -26,8 +25,8 @@ func SourceRefsEqual(left, right []source.SourceRef) bool {
return true
}
// Less defines the canonical order for NPC interaction occurrences.
func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
// Less defines the canonical order for NPC occurrences.
func Less(order shared.SourceRefOrder, left, right dnd.NPCOccurrence) bool {
leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
if leftHasEvidence != rightHasEvidence {
@@ -36,8 +35,8 @@ func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
if leftHasEvidence && leftPosition != rightPosition {
return leftPosition < rightPosition
}
leftKey := identity.ComparisonKey(left.Name)
rightKey := identity.ComparisonKey(right.Name)
leftKey := left.NPCID
rightKey := right.NPCID
if 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)
}
// ValidSourceRefs reports whether an interaction has non-empty, valid
// ValidSourceRefs reports whether an occurrence has non-empty, valid
// current-document evidence.
func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
if len(refs) == 0 {
@@ -64,13 +63,14 @@ func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
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.
func ExactIdentity(interaction dnd.NPCInteraction) string {
func ExactIdentity(occurrence dnd.NPCOccurrence) string {
var key strings.Builder
writeKeyString(&key, interaction.Name)
writeKeyString(&key, string(interaction.Kind))
for _, ref := range interaction.SourceRefs {
writeKeyString(&key, occurrence.NPCID)
writeKeyString(&key, occurrence.Name)
writeKeyString(&key, string(occurrence.Kind))
for _, ref := range occurrence.SourceRefs {
writeKeyString(&key, ref.SourceID)
writeKeyInt(&key, ref.StartUnitID)
writeKeyInt(&key, ref.EndUnitID)

View File

@@ -32,7 +32,9 @@ type Registry struct {
digest string
projectionDigest string
promptInput contracts.LLMInputMaterial
identityInput contracts.LLMInputMaterial
lookupByKey map[string]int
lookupByID map[string]int
}
// Resolver selects and memoizes immutable NPC registry views.
@@ -109,7 +111,9 @@ func emptyRegistry() *Registry {
canonical: append([]byte(nil), content...),
projectionDigest: projectionDigest,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
lookupByKey: map[string]int{},
lookupByID: map[string]int{},
}
}
@@ -129,8 +133,10 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
list := cloneNPCRegistry(value)
lookupByKey := make(map[string]int, len(list.NPCs))
lookupByID := make(map[string]int, len(list.NPCs))
for index, npc := range list.NPCs {
lookupByKey[identity.ComparisonKey(npc.Name)] = index
lookupByID[npc.ID] = index
}
digest := semanticDigest(content)
projection, err := nameProjection(list)
@@ -138,6 +144,11 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
return nil, fmt.Errorf("encode NPC name projection: %w", err)
}
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{
bound: true,
list: list,
@@ -146,6 +157,8 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
projectionDigest: projectionDigest,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
lookupByKey: lookupByKey,
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, identityProjection, identityProjectionDigest, ""),
lookupByID: lookupByID,
}, nil
}
@@ -211,6 +224,15 @@ func (r *Registry) PromptInput() contracts.LLMInputMaterial {
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
// NPC identity comparison policy.
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
}
// 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 {
sum := sha256.Sum256(content)
return "sha256:" + hex.EncodeToString(sum[:])
@@ -233,10 +267,19 @@ type projectedNPC struct {
Name string `json:"name"`
}
type identityProjectedNPC struct {
ID string `json:"id"`
Name string `json:"name"`
}
type projectedNPCRegistry struct {
NPCs []projectedNPC `json:"npcs"`
}
type identityProjectedNPCRegistry struct {
NPCs []identityProjectedNPC `json:"npcs"`
}
func nameProjection(list dnd.NPCRegistry) ([]byte, error) {
projection := projectedNPCRegistry{NPCs: make([]projectedNPC, len(list.NPCs))}
for index, npc := range list.NPCs {
@@ -245,6 +288,14 @@ func nameProjection(list dnd.NPCRegistry) ([]byte, error) {
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 {
parts := make([]string, len(issues))
for index, issue := range issues {

View File

@@ -19,7 +19,7 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
t.Fatalf("Resolve() error = %v", err)
}
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)
}
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) {
base := registryFixture()
evidenceChanged := registryFixture()

View File

@@ -20,7 +20,7 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error {
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence)
}},
{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 {
return pipeline.RegisterArtifactEvidence(registry, dnd.SceneDescriptionListKind, sceneDescriptionEvidence)
@@ -74,9 +74,9 @@ func itemEventEvidence(value dnd.ItemEventList) []source.SourceRef {
return append([]source.SourceRef(nil), refs...)
}
func npcInteractionEvidence(value dnd.NPCInteractionList) []source.SourceRef {
func npcInteractionEvidence(value dnd.NPCOccurrenceList) []source.SourceRef {
var refs []source.SourceRef
for _, record := range value.Interactions {
for _, record := range value.Occurrences {
refs = append(refs, record.SourceRefs...)
}
return append([]source.SourceRef(nil), refs...)

View File

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

View File

@@ -79,7 +79,7 @@ func registerModules(registries pipeline.Registries) error {
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists)
}},
{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 {
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)
}},
{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 {
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, "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})
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.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.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.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.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.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.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.NPCOccurrenceListKind, dnd.SceneDescriptionListKind, dnd.LocationListKind, dnd.LocationOccurrenceListKind})
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(enemyeventnormalize.Key), []contracts.ArtifactKind{dnd.EnemyEventListKind})
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})
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",
@@ -302,7 +302,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) {
"dnd_combat_turns_llm.v1.json",
"dnd_enemy_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_locations_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)
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)
}
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}}}})
}, want: []source.SourceRef{first, second}},
{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}},
{name: "scene descriptions", project: func() []source.SourceRef {
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) {
refs := []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
input := []dnd.NPCInteractionList{
input := []dnd.NPCOccurrenceList{
{},
{Interactions: []dnd.NPCInteraction{}},
{Interactions: []dnd.NPCInteraction{{Name: "Aria", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: refs}}},
{Interactions: []dnd.NPCInteraction{{Name: "Borin", Kind: dnd.NPCInteractionKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}},
{Occurrences: []dnd.NPCOccurrence{}},
{Occurrences: []dnd.NPCOccurrence{{Name: "Aria", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: refs}}},
{Occurrences: []dnd.NPCOccurrence{{Name: "Borin", Kind: dnd.NPCOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}}}},
}
got, err := appendNPCInteractionLists(input)
if err != nil {
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)
}
got.Interactions[0].SourceRefs[0].StartUnitID = 999
if input[2].Interactions[0].SourceRefs[0].StartUnitID == 999 {
got.Occurrences[0].SourceRefs[0].StartUnitID = 999
if input[2].Occurrences[0].SourceRefs[0].StartUnitID == 999 {
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)
if err != nil || result.Interactions != nil {
if err != nil || result.Occurrences != nil {
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)
}
interactions, err := appendNPCInteractionLists([]dnd.NPCInteractionList{{Interactions: []dnd.NPCInteraction{{SourceRefs: []source.SourceRef{}}}}})
if err != nil || interactions.Interactions[0].SourceRefs == nil {
interactions, err := appendNPCInteractionLists([]dnd.NPCOccurrenceList{{Occurrences: []dnd.NPCOccurrence{{SourceRefs: []source.SourceRef{}}}}})
if err != nil || interactions.Occurrences[0].SourceRefs == nil {
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)
}},
{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 {
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 {
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 NPCInteractionListKind contracts.ArtifactKind = "dnd/npc-interaction-list"
const NPCOccurrenceListKind contracts.ArtifactKind = "dnd/npc-occurrence-list"
const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list"
@@ -64,24 +64,25 @@ type CombatTurn struct {
SourceRefs []source.SourceRef `json:"source_refs"`
}
type NPCInteractionKind string
type NPCOccurrenceKind string
const (
NPCInteractionKindMentioned NPCInteractionKind = "mentioned"
NPCInteractionKindNoncombatPresence NPCInteractionKind = "noncombat_presence"
NPCInteractionKindDialogue NPCInteractionKind = "dialogue"
NPCInteractionKindCombatAlly NPCInteractionKind = "combat_ally"
NPCInteractionKindCombatOpponent NPCInteractionKind = "combat_opponent"
NPCInteractionKindOther NPCInteractionKind = "other"
NPCOccurrenceKindMentioned NPCOccurrenceKind = "mentioned"
NPCOccurrenceKindNoncombatPresence NPCOccurrenceKind = "noncombat_presence"
NPCOccurrenceKindDialogue NPCOccurrenceKind = "dialogue"
NPCOccurrenceKindCombatAlly NPCOccurrenceKind = "combat_ally"
NPCOccurrenceKindCombatOpponent NPCOccurrenceKind = "combat_opponent"
NPCOccurrenceKindOther NPCOccurrenceKind = "other"
)
type NPCInteractionList struct {
Interactions []NPCInteraction `json:"interactions"`
type NPCOccurrenceList struct {
Occurrences []NPCOccurrence `json:"occurrences"`
}
type NPCInteraction struct {
type NPCOccurrence struct {
NPCID string `json:"npc_id"`
Name string `json:"name"`
Kind NPCInteractionKind `json:"kind"`
Kind NPCOccurrenceKind `json:"kind"`
SourceRefs []source.SourceRef `json:"source_refs"`
}

View File

@@ -29,7 +29,7 @@ type Validator struct {
npcResolver *npcregistry.Resolver
}
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
@@ -72,11 +72,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
return []pipeline.CheckpointFingerprint{
{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 {
return contracts.ValidationResult{Approved: true}, nil
}
@@ -105,25 +105,28 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
}, nil
}
func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCInteractionList) bool {
for _, interaction := range value.Interactions {
if !interactionmodel.ValidSourceRefs(index, interaction.SourceRefs) {
func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCOccurrenceList) bool {
for _, occurrence := range value.Occurrences {
if !interactionmodel.ValidSourceRefs(index, occurrence.SourceRefs) {
return false
}
}
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)
for index, interaction := range value.Interactions {
prefix := fmt.Sprintf("interactions[%d]", index)
if canonical, ok := npcRegistry.Lookup(interaction.Name); ok && interaction.Name != canonical.Name {
issues = append(issues, prefix+".name is not the canonical NPC display name: "+diagnostics.Quote(interaction.Name))
for index, occurrence := range value.Occurrences {
prefix := fmt.Sprintf("occurrences[%d]", index)
canonical, ok := npcRegistry.LookupID(occurrence.NPCID)
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++ {
previous := interaction.SourceRefs[refIndex-1]
current := interaction.SourceRefs[refIndex]
for refIndex := 1; refIndex < len(occurrence.SourceRefs); refIndex++ {
previous := occurrence.SourceRefs[refIndex-1]
current := occurrence.SourceRefs[refIndex]
if order.Less(current, previous) {
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
} 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 {
return interactionmodel.Less(order, value.Interactions[left], value.Interactions[right])
if !sort.SliceIsSorted(value.Occurrences, func(left, right int) bool {
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)
for index, interaction := range value.Interactions {
key := interactionmodel.ExactIdentity(interaction)
for index, occurrence := range value.Occurrences {
key := interactionmodel.ExactIdentity(occurrence)
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
}
seen[key] = index
@@ -154,7 +157,7 @@ func Spec() pipeline.ValidatorSpec {
}
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)
if err != nil {
return nil, err

View File

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

View File

@@ -25,7 +25,7 @@ type Validator struct {
npcResolver *npcregistry.Resolver
}
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
@@ -68,11 +68,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
return []pipeline.CheckpointFingerprint{
{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 {
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
}
issues := make([]string, 0)
for index, interaction := range req.Value.Interactions {
if _, ok := npcRegistry.Lookup(interaction.Name); !ok {
issues = append(issues, fmt.Sprintf("interactions[%d].name is not in the NPC registry: %s", index, diagnostics.Quote(interaction.Name)))
for index, occurrence := range req.Value.Occurrences {
canonical, ok := npcRegistry.LookupID(occurrence.NPCID)
if !ok {
issues = append(issues, fmt.Sprintf("occurrences[%d].npc_id is not in the NPC registry: %s", index, diagnostics.Quote(occurrence.NPCID)))
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 {
@@ -111,7 +116,7 @@ func Spec() pipeline.ValidatorSpec {
}
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)
if err != nil {
return nil, err

View File

@@ -16,19 +16,25 @@ import (
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")
validator := newValidator(t, references)
value := validList(" mira thorn ")
value := validList("Mira Thorn")
result, err := validator.Validate(context.Background(), request(references, value))
if err != nil || !result.Approved {
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))
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)
}
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) {
@@ -45,7 +51,7 @@ func TestValidatorRequiresRegistryAndResolvesGeneratedReferenceAtOperationTime(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 {
t.Fatalf("empty registry result = %#v, %v", result, err)
}
@@ -78,7 +84,7 @@ func TestValidatorRejectsMalformedRegistryWithoutContentAndKeepsMetadataSafe(t *
func TestValidatorDefersShapeAndDoesNotMutateOrMisregister(t *testing.T) {
references := registryReferences(t, "Mira Thorn")
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))
if err != nil || !result.Approved {
t.Fatalf("shape deferral = %#v, %v", result, err)
@@ -107,13 +113,13 @@ func newValidator(t *testing.T, references ...contracts.ReferenceSet) *Validator
return validator
}
func request(references contracts.ReferenceSet, value dnd.NPCInteractionList) contracts.TypedValidationRequest[dnd.NPCInteractionList] {
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{References: references, Value: value}
func request(references contracts.ReferenceSet, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] {
return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{References: references, Value: value}
}
func validList(name string) dnd.NPCInteractionList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
Name: name, Kind: dnd.NPCInteractionKindDialogue,
func validList(name string) dnd.NPCOccurrenceList {
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
NPCID: identity.DeriveID(name), Name: name, Kind: dnd.NPCOccurrenceKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
}

View File

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

View File

@@ -15,22 +15,22 @@ import (
func TestValidatorOwnsRequiredNameKindAndEvidence(t *testing.T) {
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 {
t.Fatalf("Validate() = %#v, %v", result, err)
}
for _, test := range []struct {
name string
value dnd.NPCInteractionList
value dnd.NPCOccurrenceList
want string
}{
{"missing interactions", dnd.NPCInteractionList{}, "interactions must be present"},
{"blank name", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: " ", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: valid.Interactions[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"},
{"missing evidence", dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue}}}, "source_refs must contain"},
{"missing occurrences", dnd.NPCOccurrenceList{}, "occurrences must be present"},
{"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.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn", Kind: "unsupported", SourceRefs: valid.Occurrences[0].SourceRefs}}}, "kind is unsupported"},
{"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) {
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) {
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) {
value := dnd.NPCInteractionList{Interactions: make([]dnd.NPCInteraction, 24)}
for index := range value.Interactions {
value.Interactions[index] = dnd.NPCInteraction{Name: strings.Repeat("火", 220) + "\n", Kind: "unsupported"}
value := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, 24)}
for index := range value.Occurrences {
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") {
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")
}
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 {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue,
func validList() dnd.NPCOccurrenceList {
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
}}}
}

View File

@@ -22,7 +22,7 @@ const (
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
@@ -34,7 +34,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
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 {
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)
issues := make([]string, 0)
for interactionIndex, interaction := range req.Value.Interactions {
for refIndex, ref := range interaction.SourceRefs {
for occurrenceIndex, occurrence := range req.Value.Occurrences {
for refIndex, ref := range occurrence.SourceRefs {
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
}
if req.Stage == string(pipeline.StageExtract) && !chunkContainsRef(req.Chunk, ref) {
issues = append(issues, fmt.Sprintf(
"interactions[%d].source_refs[%d]: source reference is outside the current extraction chunk",
interactionIndex, refIndex,
"occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk",
occurrenceIndex, refIndex,
))
}
}
@@ -85,7 +85,7 @@ func Spec() pipeline.ValidatorSpec {
}
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)
if err != nil {
return nil, err

View File

@@ -18,13 +18,13 @@ func TestValidatorOwnsCurrentSourceUnitAndRangeValidation(t *testing.T) {
if err != nil || !result.Approved {
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: "session", StartUnitID: 99, EndUnitID: 99},
{SourceID: "session", StartUnitID: 2, EndUnitID: 1},
}
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)
}
}
@@ -37,7 +37,7 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t
Units: append([]source.SourceUnit(nil), doc.Units[:2]...),
}
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),
Source: doc,
Chunk: chunk,
@@ -47,10 +47,10 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *t
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,
}}
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),
Source: doc,
Chunk: chunk,
@@ -62,7 +62,7 @@ func TestValidatorRejectsDocumentValidEvidenceOutsideCurrentExtractionChunk(t *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),
Source: document(),
Value: validList(),
@@ -73,7 +73,7 @@ func TestValidatorRequiresChunkDuringExtractValidation(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))
if err != nil || !result.Approved {
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] {
return contracts.TypedValidationRequest[dnd.NPCInteractionList]{Source: doc, Value: value}
func request(doc *source.SourceDocument, value dnd.NPCOccurrenceList) contracts.TypedValidationRequest[dnd.NPCOccurrenceList] {
return contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: doc, Value: value}
}
func document() *source.SourceDocument {
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}}
}
func validList() dnd.NPCInteractionList {
return dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{{
Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue,
func validList() dnd.NPCOccurrenceList {
return dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{{
NPCID: "npc:test", Name: "Mira Thorn", Kind: dnd.NPCOccurrenceKindDialogue,
SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}},
}}}
}

View File

@@ -23,7 +23,7 @@ const (
type Options struct{}
type Validator struct{}
var _ contracts.TypedValidator[dnd.NPCInteractionList] = (*Validator)(nil)
var _ contracts.TypedValidator[dnd.NPCOccurrenceList] = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(Options) *Validator { return &Validator{} }
@@ -35,7 +35,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
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 {
return contracts.ValidationResult{Approved: true}, nil
}
@@ -43,23 +43,23 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
citedTexts := make([]string, len(req.Value.Interactions))
for index, interaction := range req.Value.Interactions {
citedText, err := resolver.CitedText(interaction.SourceRefs)
citedTexts := make([]string, len(req.Value.Occurrences))
for index, occurrence := range req.Value.Occurrences {
citedText, err := resolver.CitedText(occurrence.SourceRefs)
if err != nil {
return contracts.ValidationResult{Approved: true}, nil
}
citedTexts[index] = citedText
}
warnings := make([]contracts.Warning, 0)
for index, interaction := range req.Value.Interactions {
if shared.ContainsTokenSequence(citedTexts[index], interaction.Name) {
for index, occurrence := range req.Value.Occurrences {
if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) {
continue
}
warnings = append(warnings, contracts.Warning{
Scope: fmt.Sprintf("interactions[%d]", index),
Scope: fmt.Sprintf("occurrences[%d]", index),
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{
@@ -73,7 +73,7 @@ func Spec() pipeline.ValidatorSpec {
}
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)
if err != nil {
return nil, err

View File

@@ -14,34 +14,34 @@ import (
)
func TestValidatorUsesOnlyCurrentTranscriptAndWarnsOncePerInteraction(t *testing.T) {
value := dnd.NPCInteractionList{Interactions: []dnd.NPCInteraction{
{Name: "O'Rin Thorn", Kind: dnd.NPCInteractionKindDialogue, 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}}},
value := dnd.NPCOccurrenceList{Occurrences: []dnd.NPCOccurrence{
{NPCID: "npc:test", Name: "O'Rin Thorn", Kind: dnd.NPCOccurrenceKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}},
{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."}}}
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 {
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)
}
}
func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
for _, value := range []dnd.NPCInteractionList{
{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn"}}},
{Interactions: []dnd.NPCInteraction{{Name: "Mira Thorn", Kind: dnd.NPCInteractionKindDialogue, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}},
for _, value := range []dnd.NPCOccurrenceList{
{Occurrences: []dnd.NPCOccurrence{{NPCID: "npc:test", Name: "Mira Thorn"}}},
{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 {
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
_, 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) {
t.Fatalf("Validate() mutated value: %#v", value)
}
@@ -53,17 +53,18 @@ func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
func TestValidatorBoundsWarnings(t *testing.T) {
count := diagnostics.MaxWarnings + 5
interactions := make([]dnd.NPCInteraction, count)
interactions := make([]dnd.NPCOccurrence, count)
for index := range interactions {
interactions[index] = dnd.NPCInteraction{
interactions[index] = dnd.NPCOccurrence{
NPCID: "npc:test",
Name: "Missing NPC",
Kind: dnd.NPCInteractionKindMentioned,
Kind: dnd.NPCOccurrenceKindMentioned,
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."}}},
Value: dnd.NPCInteractionList{Interactions: interactions},
Value: dnd.NPCOccurrenceList{Occurrences: interactions},
})
if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v", result, err)

View File

@@ -36,7 +36,7 @@ func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing
}
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 {
t.Fatalf("interaction registry input = %s, want names-only projection %s", got, wantRegistry)
}
@@ -52,25 +52,25 @@ func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing
if err != nil {
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)
}
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" {
t.Fatalf("interactions = %#v, want canonical names, kinds, and source chronology", interactions)
}
assertInteractionEvidence(t, first.SourceRefs)
assertInteractionEvidence(t, second.SourceRefs)
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
if err := json.Unmarshal(serialized.Artifact.Content, &durable); err != nil {
t.Fatalf("unmarshal durable interaction payload: %v", err)
}
if len(durable) != 1 || durable["interactions"] == nil {
t.Fatalf("durable interaction payload = %#v, want only interactions", durable)
if len(durable) != 1 || durable["occurrences"] == nil {
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)
}
registryRequest := client.requestFor(t, interactionextract.PromptID)
if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"name":"Mira Thorn"}]}` {
t.Fatalf("downstream registry = %s, want one canonical names-only NPC", got)
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 NPC identity", got)
}
manifestContent, err := json.Marshal(output.Manifest)
if err != nil {
@@ -228,9 +228,9 @@ func (client *npcInteractionLLMClient) CompleteStructured(ctx context.Context, r
case npcnormalize.PromptID:
payload = map[string]any{"duplicate_groups": []any{}}
case interactionextract.PromptID:
payload = map[string]any{"interactions": []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{"name": " mira thorn ", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 2, "end_unit_id": 2}}},
payload = map[string]any{"occurrences": []any{
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{"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:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected interaction prompt %q", request.PromptID)
@@ -291,7 +291,7 @@ func (client *semanticNPCInteractionClient) CompleteStructured(_ context.Context
case npcnormalize.PromptID:
payload = map[string]any{"duplicate_groups": []any{map[string]any{"members": []string{"candidate-000001", "candidate-000002"}, "canonical": "candidate-000001"}}}
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:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected semantic pipeline prompt %q", request.PromptID)
}