Move NPC occurrences to their canonical namespace

This commit is contained in:
2026-08-05 19:00:55 +00:00
parent 5e2ccffc0f
commit 2f61118e78
59 changed files with 518 additions and 518 deletions

View File

@@ -14,73 +14,73 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
interactioncodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcinteractions"
occurrencecodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcoccurrences"
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry"
interactionextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcinteractions"
occurrenceextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcoccurrences"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcregistry"
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcregistry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
)
func TestNPCInteractionPipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing.T) {
func TestNPCOccurrencePipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing.T) {
registries := productionNPCRegistries(t)
resolved := resolveNPCInteractionPipeline(t, registries)
client := &npcInteractionLLMClient{}
resolved := resolveNPCOccurrencePipeline(t, registries)
client := &npcOccurrenceLLMClient{}
output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: readNPCFixture(t)})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 2 {
t.Fatalf("run outputs = %#v rejected = %#v, want NPC and interaction artifacts", output.NormalizeOutputs, output.Rejected)
t.Fatalf("run outputs = %#v rejected = %#v, want NPC and occurrence artifacts", output.NormalizeOutputs, output.Rejected)
}
request := client.requestFor(t, interactionextract.PromptID)
request := client.requestFor(t, occurrenceextract.PromptID)
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)
t.Fatalf("occurrence registry input = %s, want names-only projection %s", got, wantRegistry)
}
if request.Inputs["npc_registry"].MediaType != npccodec.MediaType {
t.Fatalf("interaction registry media type = %q, want %q", request.Inputs["npc_registry"].MediaType, npccodec.MediaType)
t.Fatalf("occurrence registry media type = %q, want %q", request.Inputs["npc_registry"].MediaType, npccodec.MediaType)
}
serialized := normalizedLane(t, output, "interactions")
if serialized.Artifact.Schema.ID != interactioncodec.SchemaID || serialized.Artifact.Schema.Version != interactioncodec.SchemaVersion {
t.Fatalf("interaction artifact schema = %#v", serialized.Artifact.Schema)
serialized := normalizedLane(t, output, "occurrences")
if serialized.Artifact.Schema.ID != occurrencecodec.SchemaID || serialized.Artifact.Schema.Version != occurrencecodec.SchemaVersion {
t.Fatalf("occurrence artifact schema = %#v", serialized.Artifact.Schema)
}
interactions, err := interactioncodec.New().Decode(serialized.Artifact.Content)
occurrences, err := occurrencecodec.New().Decode(serialized.Artifact.Content)
if err != nil {
t.Fatalf("Decode(interaction output) error = %v", err)
t.Fatalf("Decode(occurrence output) error = %v", err)
}
if len(interactions.Occurrences) != 2 {
t.Fatalf("interactions = %#v, want two occurrences", interactions)
if len(occurrences.Occurrences) != 2 {
t.Fatalf("occurrences = %#v, want two occurrences", occurrences)
}
first, second := interactions.Occurrences[0], interactions.Occurrences[1]
first, second := occurrences.Occurrences[0], occurrences.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)
t.Fatalf("occurrences = %#v, want canonical names, kinds, and source chronology", occurrences)
}
assertInteractionEvidence(t, first.SourceRefs)
assertInteractionEvidence(t, second.SourceRefs)
assertOccurrenceEvidence(t, first.SourceRefs)
assertOccurrenceEvidence(t, second.SourceRefs)
if first.SourceRefs[0].StartUnitID >= second.SourceRefs[0].StartUnitID {
t.Fatalf("interaction chronology = %#v, want source order", interactions.Occurrences)
t.Fatalf("occurrence chronology = %#v, want source order", occurrences.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)
t.Fatalf("unmarshal durable occurrence payload: %v", err)
}
if len(durable) != 1 || durable["occurrences"] == nil {
t.Fatalf("durable interaction payload = %#v, want only occurrences", durable)
t.Fatalf("durable occurrence payload = %#v, want only occurrences", durable)
}
}
func TestSemanticNPCNormalizationCrossesOrderedRegistryHandoff(t *testing.T) {
registries := productionNPCRegistries(t)
cfg := loadNPCInteractionPipelineConfig(t)
profile := cfg.Pipelines["dnd-npc-interactions-fixture"]
cfg := loadNPCOccurrencePipelineConfig(t)
profile := cfg.Pipelines["dnd-npc-occurrences-fixture"]
profile.Chunk = pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"max_units": 1}}
cfg.Pipelines["dnd-npc-interactions-fixture"] = profile
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-npc-interactions-fixture", Catalog: moduleCatalog(registries)})
cfg.Pipelines["dnd-npc-occurrences-fixture"] = profile
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-npc-occurrences-fixture", Catalog: moduleCatalog(registries)})
if err != nil {
t.Fatal(err)
}
@@ -88,7 +88,7 @@ func TestSemanticNPCNormalizationCrossesOrderedRegistryHandoff(t *testing.T) {
if err != nil || len(warnings) != 0 {
t.Fatalf("MaterializeReferences() error = %v warnings = %#v", err, warnings)
}
client := &semanticNPCInteractionClient{}
client := &semanticNPCOccurrenceClient{}
raw := []byte(`{"metadata":{"id":"semantic-session","title":"Semantic NPC session"},"segments":[{"id":1,"start":0,"end":1,"speaker":"DM","text":"Mira Thorn enters."},{"id":2,"start":1,"end":2,"speaker":"DM","text":"Mira Thorn, the Greencloak, waves."}]}`)
output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: raw})
if err != nil {
@@ -108,11 +108,11 @@ func TestSemanticNPCNormalizationCrossesOrderedRegistryHandoff(t *testing.T) {
if refs := npcsValue.NPCs[0].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "semantic-session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "semantic-session", StartUnitID: 2, EndUnitID: 2}}) {
t.Fatalf("NPC evidence = %#v, want original extraction evidence union", refs)
}
interactionOutput := normalizedLane(t, output, "interactions")
if interactionOutput.StepID != "extract-interactions" {
t.Fatalf("interaction output step = %q, want ordered downstream step", interactionOutput.StepID)
occurrenceOutput := normalizedLane(t, output, "occurrences")
if occurrenceOutput.StepID != "extract-occurrences" {
t.Fatalf("occurrence output step = %q, want ordered downstream step", occurrenceOutput.StepID)
}
registryRequest := client.requestFor(t, interactionextract.PromptID)
registryRequest := client.requestFor(t, occurrenceextract.PromptID)
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)
}
@@ -127,10 +127,10 @@ func TestSemanticNPCNormalizationCrossesOrderedRegistryHandoff(t *testing.T) {
}
}
func TestNPCInteractionPipelineSkipsConsumerWhenNPCProducerIsRejected(t *testing.T) {
func TestNPCOccurrencePipelineSkipsConsumerWhenNPCProducerIsRejected(t *testing.T) {
registries := productionNPCRegistries(t)
resolved := resolveNPCInteractionPipeline(t, registries)
client := &npcInteractionLLMClient{rejectNPCs: true}
resolved := resolveNPCOccurrencePipeline(t, registries)
client := &npcOccurrenceLLMClient{rejectNPCs: true}
output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: readNPCFixture(t)})
if err == nil {
@@ -139,15 +139,15 @@ func TestNPCInteractionPipelineSkipsConsumerWhenNPCProducerIsRejected(t *testing
if client.requestCount(npcregistry.PromptID) != 1 {
t.Fatalf("NPC requests = %d, want rejected producer", client.requestCount(npcregistry.PromptID))
}
if client.requestCount(interactionextract.PromptID) != 0 {
t.Fatalf("interaction requests = %d, want none after rejected producer", client.requestCount(interactionextract.PromptID))
if client.requestCount(occurrenceextract.PromptID) != 0 {
t.Fatalf("occurrence requests = %d, want none after rejected producer", client.requestCount(occurrenceextract.PromptID))
}
}
func resolveNPCInteractionPipeline(t *testing.T, registries pipeline.Registries) pipeline.ResolvedPipeline {
func resolveNPCOccurrencePipeline(t *testing.T, registries pipeline.Registries) pipeline.ResolvedPipeline {
t.Helper()
configValue := loadNPCInteractionPipelineConfig(t)
effective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-npc-interactions-fixture", Catalog: moduleCatalog(registries)})
configValue := loadNPCOccurrencePipelineConfig(t)
effective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-npc-occurrences-fixture", Catalog: moduleCatalog(registries)})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
@@ -158,11 +158,11 @@ func resolveNPCInteractionPipeline(t *testing.T, registries pipeline.Registries)
return resolved
}
func loadNPCInteractionPipelineConfig(t *testing.T) config.Config {
func loadNPCOccurrencePipelineConfig(t *testing.T) config.Config {
t.Helper()
data, err := os.ReadFile("testdata/dnd_npc_interactions_pipeline.yml")
data, err := os.ReadFile("testdata/dnd_npc_occurrences_pipeline.yml")
if err != nil {
t.Fatalf("ReadFile(dnd_npc_interactions_pipeline.yml) error = %v", err)
t.Fatalf("ReadFile(dnd_npc_occurrences_pipeline.yml) error = %v", err)
}
fileConfig, err := config.ParseFileConfigYAML(data)
if err != nil {
@@ -186,25 +186,25 @@ func normalizedLane(t *testing.T, output pipeline.RunOutput, laneID string) cont
return contracts.SerializedOutput{}
}
func assertInteractionEvidence(t *testing.T, references []source.SourceRef) {
func assertOccurrenceEvidence(t *testing.T, references []source.SourceRef) {
t.Helper()
if len(references) == 0 {
t.Fatal("interaction has no current-source evidence")
t.Fatal("occurrence has no current-source evidence")
}
for _, reference := range references {
if reference.SourceID != "npc-session" {
t.Fatalf("interaction evidence = %#v, want current source only", reference)
t.Fatalf("occurrence evidence = %#v, want current source only", reference)
}
}
}
type npcInteractionLLMClient struct {
type npcOccurrenceLLMClient struct {
mu sync.Mutex
requests []contracts.StructuredCompletionRequest
rejectNPCs bool
}
func (client *npcInteractionLLMClient) CompleteStructured(ctx context.Context, request contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
func (client *npcOccurrenceLLMClient) CompleteStructured(ctx context.Context, request contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
if err := ctx.Err(); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
@@ -227,25 +227,25 @@ func (client *npcInteractionLLMClient) CompleteStructured(ctx context.Context, r
}
case npcnormalize.PromptID:
payload = map[string]any{"duplicate_groups": []any{}}
case interactionextract.PromptID:
case occurrenceextract.PromptID:
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)
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected occurrence prompt %q", request.PromptID)
}
content, err := json.Marshal(payload)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate interaction response: %w", err)
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate occurrence response: %w", err)
}
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "interaction-fake"}, nil
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "occurrence-fake"}, nil
}
func (client *npcInteractionLLMClient) requestFor(t *testing.T, promptID string) contracts.StructuredCompletionRequest {
func (client *npcOccurrenceLLMClient) requestFor(t *testing.T, promptID string) contracts.StructuredCompletionRequest {
t.Helper()
client.mu.Lock()
defer client.mu.Unlock()
@@ -258,7 +258,7 @@ func (client *npcInteractionLLMClient) requestFor(t *testing.T, promptID string)
return contracts.StructuredCompletionRequest{}
}
func (client *npcInteractionLLMClient) requestCount(promptID string) int {
func (client *npcOccurrenceLLMClient) requestCount(promptID string) int {
client.mu.Lock()
defer client.mu.Unlock()
count := 0
@@ -270,14 +270,14 @@ func (client *npcInteractionLLMClient) requestCount(promptID string) int {
return count
}
var _ contracts.StructuredLLMClient = (*npcInteractionLLMClient)(nil)
var _ contracts.StructuredLLMClient = (*npcOccurrenceLLMClient)(nil)
type semanticNPCInteractionClient struct {
type semanticNPCOccurrenceClient struct {
requests []contracts.StructuredCompletionRequest
npcCalls int
}
func (client *semanticNPCInteractionClient) CompleteStructured(_ context.Context, request contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
func (client *semanticNPCOccurrenceClient) CompleteStructured(_ context.Context, request contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
client.requests = append(client.requests, cloneStructuredCompletionRequest(request))
var payload any
switch request.PromptID {
@@ -290,7 +290,7 @@ func (client *semanticNPCInteractionClient) CompleteStructured(_ context.Context
payload = map[string]any{"npcs": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": client.npcCalls, "end_unit_id": client.npcCalls}}}}}
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:
case occurrenceextract.PromptID:
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)
@@ -305,7 +305,7 @@ func (client *semanticNPCInteractionClient) CompleteStructured(_ context.Context
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func (client *semanticNPCInteractionClient) requestCount(promptID string) int {
func (client *semanticNPCOccurrenceClient) requestCount(promptID string) int {
count := 0
for _, request := range client.requests {
if request.PromptID == promptID {
@@ -315,7 +315,7 @@ func (client *semanticNPCInteractionClient) requestCount(promptID string) int {
return count
}
func (client *semanticNPCInteractionClient) requestFor(t *testing.T, promptID string) contracts.StructuredCompletionRequest {
func (client *semanticNPCOccurrenceClient) requestFor(t *testing.T, promptID string) contracts.StructuredCompletionRequest {
t.Helper()
for _, request := range client.requests {
if request.PromptID == promptID {
@@ -326,4 +326,4 @@ func (client *semanticNPCInteractionClient) requestFor(t *testing.T, promptID st
return contracts.StructuredCompletionRequest{}
}
var _ contracts.StructuredLLMClient = (*semanticNPCInteractionClient)(nil)
var _ contracts.StructuredLLMClient = (*semanticNPCOccurrenceClient)(nil)

View File

@@ -8,7 +8,7 @@ cache:
debug:
directory: ./notarius-debug
pipelines:
dnd-npc-interactions-fixture:
dnd-npc-occurrences-fixture:
input: seriatim
steps:
- id: identify-npcs
@@ -16,13 +16,13 @@ pipelines:
npc_registry:
extract: dnd/npc-registry
normalize: dnd/npc-registry
- id: extract-interactions
- id: extract-occurrences
references:
npc_registry:
artifact:
step: identify-npcs
lane: npc_registry
artifacts:
interactions:
extract: dnd/npc-interactions
normalize: dnd/npc-interactions
occurrences:
extract: dnd/npc-occurrences
normalize: dnd/npc-occurrences