Ground NPC occurrences by canonical names

This commit is contained in:
2026-08-08 14:37:54 +00:00
parent 516af12916
commit ece1bca460
18 changed files with 1088 additions and 98 deletions

View File

@@ -224,12 +224,13 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
}
}
for _, test := range []struct {
promptID string
slot string
name string
promptID string
slot string
name string
requiresIDs bool
}{
{promptID: npcoccurrences.PromptID, slot: "npc_registry", name: "Kesh"},
{promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade"},
{promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade", requiresIDs: true},
} {
requests := client.requestsFor(test.promptID)
if len(requests) != 2 {
@@ -237,8 +238,9 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
}
for _, request := range requests {
registryInput := request.Inputs[test.slot]
if !strings.Contains(string(registryInput.Content), test.name) || !strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") {
t.Fatalf("%s registry input = %q, want source-free ID grounding", test.promptID, registryInput.Content)
hasID := strings.Contains(string(registryInput.Content), `"id"`)
if !strings.Contains(string(registryInput.Content), test.name) || hasID != test.requiresIDs || strings.Contains(string(registryInput.Content), "source_refs") {
t.Fatalf("%s registry input = %q, want source-free configured grounding", test.promptID, registryInput.Content)
}
}
}

View File

@@ -1,10 +1,12 @@
package npcoccurrences
import (
"fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
@@ -50,12 +52,16 @@ func canonicalizeOccurrence(occurrence *occurrenceResponse, order shared.SourceR
return order.EarliestValid(refs)
}
func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.NPCOccurrenceList {
func canonicalOccurrenceList(response extractionResponse, sourceID string, registry *npcregistry.Registry) (dnd.NPCOccurrenceList, error) {
occurrences := make([]dnd.NPCOccurrence, len(response.Occurrences))
for index, occurrence := range response.Occurrences {
canonical, ok := registry.Lookup(occurrence.Name)
if !ok {
return dnd.NPCOccurrenceList{}, fmt.Errorf("occurrences[%d].name is not in the NPC registry", index)
}
occurrences[index] = dnd.NPCOccurrence{
NPCID: occurrence.NPCID,
Name: occurrence.Name,
NPCID: canonical.ID,
Name: canonical.Name,
Kind: dnd.NPCOccurrenceKind(occurrence.Kind),
SourceRefs: canonicalSourceRefs(occurrence.SourceRefs, sourceID),
}
@@ -63,7 +69,7 @@ func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.N
if response.Occurrences == nil {
occurrences = nil
}
return dnd.NPCOccurrenceList{Occurrences: occurrences}
return dnd.NPCOccurrenceList{Occurrences: occurrences}, nil
}
func canonicalSourceRefs(refs []occurrenceSourceRefResponse, sourceID string) []source.SourceRef {

View File

@@ -14,7 +14,7 @@ import (
const (
Key = "dnd/npc-occurrences"
mappingPolicy = "dnd.npc_occurrences.extract_mapping.v2"
mappingPolicy = "dnd.npc_occurrences.extract_mapping.v3"
)
const (
@@ -132,7 +132,7 @@ 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.IdentityPromptInput().Digest},
{Name: "npc_registry", Value: seeded.IdentityDigest()},
}
}
@@ -158,7 +158,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
var response extractionResponse
inputs := shared.PromptInputs(sourceInput, req.References)
inputs[NPCRegistryReferenceSlot] = npcRegistry.IdentityPromptInput()
inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput()
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key,
PromptID: PromptID,
@@ -170,26 +170,13 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
}
canonicalizeResponse(&response, order, req.Source.ID)
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)
value, err := canonicalOccurrenceList(response, req.Source.ID, npcRegistry)
if err != nil {
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("resolve NPC names against registry: %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 {
return pipeline.ModuleSpec{
Key: Key,

View File

@@ -18,13 +18,13 @@ import (
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{NPCID: identity.DeriveID("Other"), Name: "Other", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
{NPCID: identity.DeriveID("Opponent"), Name: "Opponent", Kind: "combat_opponent", SourceRefs: occurrenceRefs(20, 20)},
{NPCID: identity.DeriveID("Ally"), Name: "Ally", Kind: "combat_ally", SourceRefs: occurrenceRefs(5, 5)},
{NPCID: identity.DeriveID("Speaker"), Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)},
{NPCID: identity.DeriveID("Present"), Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 7)},
{NPCID: identity.DeriveID("Mentioned"), Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
{NPCID: identity.DeriveID("Invalid"), Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)},
{Name: "Other", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
{Name: "Opponent", Kind: "combat_opponent", SourceRefs: occurrenceRefs(20, 20)},
{Name: "Ally", Kind: "combat_ally", SourceRefs: occurrenceRefs(5, 5)},
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)},
{Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 7)},
{Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
{Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)},
}}}
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
req := extractionRequest()
@@ -51,6 +51,9 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
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 id := result.Value.Occurrences[0].NPCID; id != identity.DeriveID("Mentioned") {
t.Fatalf("durable NPC ID = %q, want registry identity", id)
}
if invalid := result.Value.Occurrences[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid)
}
@@ -61,14 +64,14 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
func TestExtractUsesDocumentOrderForReferencesAndOccurrences(t *testing.T) {
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{NPCID: identity.DeriveID("Later"), Name: "Later", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
{NPCID: identity.DeriveID("First"), Name: "First", Kind: "mentioned", SourceRefs: []occurrenceSourceRefResponse{
{Name: "Later", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
{Name: "First", Kind: "mentioned", SourceRefs: []occurrenceSourceRefResponse{
{StartUnitID: 10, EndUnitID: 10},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 30, EndUnitID: 30},
{StartUnitID: 999, EndUnitID: 0},
}},
{NPCID: identity.DeriveID("Second"), Name: "Second", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
{Name: "Second", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
}}}
references := requiredRegistryReferences(t, "Later", "First", "Second")
req := extractionRequest()
@@ -107,9 +110,9 @@ func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
}
}
func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) {
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
NPCID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10),
Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10),
}}}}
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
req := extractionRequest()
@@ -119,10 +122,10 @@ func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
}
request := client.requests[0]
registry := request.Inputs[NPCRegistryReferenceSlot]
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)
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 names-only projection", registry)
}
for _, forbidden := range []string{"other-session", "start_unit_id"} {
for _, forbidden := range []string{"npc:sha256:", "other-session", "start_unit_id"} {
if strings.Contains(string(registry.Content), forbidden) {
t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content)
}
@@ -162,24 +165,37 @@ func TestExtractRequiresBoundRegistryBeforeLLMCall(t *testing.T) {
}
}
func TestExtractRejectsUnknownIDsAndMismatchedNames(t *testing.T) {
func TestExtractRejectsUnknownNamesWithoutPartialResult(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: occurrenceRefs(10, 10)}, "npc_id is not in the NPC registry"},
{"mismatched name", occurrenceResponse{NPCID: identity.DeriveID("Mira Thorn"), Name: "Hooded Guard", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, "name does not match npc_id"},
} {
t.Run(test.name, func(t *testing.T) {
client := &fakeOccurrencesLLMClient{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)
}
})
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
{Name: "Unknown NPC", Kind: "mentioned", SourceRefs: occurrenceRefs(20, 20)},
}}}
req := extractionRequest()
req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "name is not in the NPC registry") {
t.Fatalf("Extract() error = %v, want unknown name failure", err)
}
if len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() returned partial result = %#v", result.Value)
}
}
func TestExtractCanonicalizesComparisonEquivalentRegistryNames(t *testing.T) {
references := requiredRegistryReferences(t, "Mira Thorn")
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
Name: " mIRA\u2003thorn ", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10),
}}}}
req := extractionRequest()
req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
if err != nil {
t.Fatalf("Extract() error = %v", err)
}
occurrence := result.Value.Occurrences[0]
if occurrence.Name != "Mira Thorn" || occurrence.NPCID != identity.DeriveID("Mira Thorn") {
t.Fatalf("canonical occurrence = %#v", occurrence)
}
}
@@ -192,7 +208,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":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` || input.OriginURI != "" {
if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" {
t.Fatalf("generated registry prompt input = %#v", input)
}
metadata := extractor.ManifestMetadata()
@@ -221,6 +237,24 @@ func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) {
}
}
func TestExtractRejectsNonemptyResponseForEmptyBoundRegistry(t *testing.T) {
content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{}})
if err != nil {
t.Fatal(err)
}
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: {
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot},
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
}}}
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}}}}
req := extractionRequest()
req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "name is not in the NPC registry") || len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() = %#v, %v; want no accepted occurrences", result, err)
}
}
func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
references := requiredRegistryReferences(t, "Mira Thorn")
valid := extractionRequest()

View File

@@ -5,7 +5,6 @@ type extractionResponse struct {
}
type occurrenceResponse struct {
NPCID string `json:"npc_id"`
Name string `json:"name"`
Kind string `json:"kind"`
SourceRefs []occurrenceSourceRefResponse `json:"source_refs"`

View File

@@ -6,13 +6,13 @@ import (
)
func TestExtractionResponsePreservesValidatorOwnedSemantics(t *testing.T) {
content := []byte(`{"occurrences":[{"npc_id":"","name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
content := []byte(`{"occurrences":[{"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)
}
occurrence := response.Occurrences[0]
if occurrence.NPCID != "" || occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (occurrenceSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
if occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (occurrenceSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
t.Fatalf("decoded response = %#v", occurrence)
}
}

View File

@@ -42,7 +42,7 @@ func TestRegisterPromptAssetsAndPrepareOccurrencePrompt(t *testing.T) {
"players": promptkit.Inline("occurrence-player"),
"party": promptkit.Inline("Mira: ranger"),
"glossary": promptkit.Inline("Greencloak: title"),
"npc_registry": promptkit.Inline(`{"npcs":[{"id":"npc:sha256:test","name":"occurrence-npc"}]}`),
"npc_registry": promptkit.Inline(`{"npcs":[{"name":"occurrence-npc"}]}`),
},
})
if err != nil {

View File

@@ -42,9 +42,9 @@ 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["npc_id"] = "npc:sha256:opaque" },
func(record map[string]any) { record["unexpected"] = true },
func(record map[string]any) {
record["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later"
@@ -81,7 +81,7 @@ func TestResponseSchemaIsDefensiveAndContentSafe(t *testing.T) {
func validOccurrenceResponse() map[string]any {
return map[string]any{"occurrences": []any{map[string]any{
"npc_id": "npc:sha256:test", "name": "Mira Thorn", "kind": "dialogue",
"name": "Mira Thorn", "kind": "dialogue",
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
}}}
}

View File

@@ -92,7 +92,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
return []pipeline.CheckpointFingerprint{
{Name: "normalization_policy", Value: normalizationPolicy},
{Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityPromptInput().Digest},
{Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityDigest()},
}
}

View File

@@ -31,8 +31,8 @@ type Registry struct {
canonical []byte
digest string
projectionDigest string
identityDigest string
promptInput contracts.LLMInputMaterial
identityInput contracts.LLMInputMaterial
lookupByKey map[string]int
lookupByID map[string]int
}
@@ -110,8 +110,8 @@ func emptyRegistry() *Registry {
list: dnd.NPCRegistry{NPCs: []dnd.NPC{}},
canonical: append([]byte(nil), content...),
projectionDigest: projectionDigest,
identityDigest: 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{},
}
@@ -155,9 +155,9 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
canonical: append([]byte(nil), content...),
digest: digest,
projectionDigest: projectionDigest,
identityDigest: identityProjectionDigest,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
lookupByKey: lookupByKey,
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, identityProjection, identityProjectionDigest, ""),
lookupByID: lookupByID,
}, nil
}
@@ -207,6 +207,15 @@ func (r *Registry) ProjectionDigest() string {
return r.projectionDigest
}
// IdentityDigest returns the SHA-256 digest of the ordered ID/name identity
// projection. It binds durable identities without exposing them to the model.
func (r *Registry) IdentityDigest() string {
if r == nil {
return ""
}
return r.identityDigest
}
// Count returns the number of validated NPC records.
func (r *Registry) Count() int {
if r == nil {
@@ -224,15 +233,6 @@ 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) {

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 || string(registry.IdentityPromptInput().Content) != emptyPrompt {
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt || registry.IdentityDigest() != registry.ProjectionDigest() {
t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input)
}
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
@@ -46,12 +46,15 @@ func TestResolveKeepsDurableProvenanceAndProjectsOnlyOrderedNames(t *testing.T)
}
}
func TestIdentityPromptInputProjectsOrderedIDsAndNames(t *testing.T) {
func TestIdentityDigestTracksOrderedIDsAndNames(t *testing.T) {
registry := resolveList(t, registryFixture())
input := registry.IdentityPromptInput()
projection, err := identityProjection(registryFixture())
if err != nil {
t.Fatal(err)
}
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 string(projection) != want || registry.IdentityDigest() != semanticDigest(projection) || registry.IdentityDigest() == registry.ProjectionDigest() {
t.Fatalf("identity digest = %q, want digest for %s", registry.IdentityDigest(), want)
}
if npc, ok := registry.LookupID(identity.DeriveID("Mira Thorn")); !ok || npc.Name != "Mira Thorn" {
t.Fatalf("LookupID() = %#v, %t", npc, ok)
@@ -213,7 +216,7 @@ func TestProjectionIsStableForEquivalentNormalizedRegistries(t *testing.T) {
secondList := registryFixture()
secondList.NPCs[0].SourceRefs = append(secondList.NPCs[0].SourceRefs, source.SourceRef{SourceID: "session-beta", StartUnitID: 8, EndUnitID: 8})
second := resolveList(t, secondList)
if !reflect.DeepEqual(first.PromptInput().Content, second.PromptInput().Content) || first.ProjectionDigest() != second.ProjectionDigest() || first.Digest() == second.Digest() {
if !reflect.DeepEqual(first.PromptInput().Content, second.PromptInput().Content) || first.ProjectionDigest() != second.ProjectionDigest() || first.IdentityDigest() != second.IdentityDigest() || first.Digest() == second.Digest() {
t.Fatalf("projection/full identity mismatch: %#v %#v", first, second)
}
}

View File

@@ -72,7 +72,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
return []pipeline.CheckpointFingerprint{
{Name: "policy", Value: policy},
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityDigest()},
}
}

View File

@@ -68,7 +68,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
}
return []pipeline.CheckpointFingerprint{
{Name: "policy", Value: policy},
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityDigest()},
}
}

View File

@@ -36,7 +36,7 @@ func TestNPCOccurrencePipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing.
}
request := client.requestFor(t, occurrenceextract.PromptID)
wantRegistry := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Hooded Guard") + `","name":"Hooded Guard"}]}`
wantRegistry := `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}`
if got := string(request.Inputs["npc_registry"].Content); got != wantRegistry {
t.Fatalf("occurrence registry input = %s, want names-only projection %s", got, wantRegistry)
}
@@ -59,6 +59,9 @@ func TestNPCOccurrencePipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing.
if first.Name != "Mira Thorn" || string(first.Kind) != "dialogue" || second.Name != "Hooded Guard" || string(second.Kind) != "noncombat_presence" {
t.Fatalf("occurrences = %#v, want canonical names, kinds, and source chronology", occurrences)
}
if first.NPCID != identity.DeriveID("Mira Thorn") || second.NPCID != identity.DeriveID("Hooded Guard") {
t.Fatalf("occurrences = %#v, want durable registry IDs", occurrences)
}
assertOccurrenceEvidence(t, first.SourceRefs)
assertOccurrenceEvidence(t, second.SourceRefs)
if first.SourceRefs[0].StartUnitID >= second.SourceRefs[0].StartUnitID {
@@ -113,8 +116,8 @@ func TestSemanticNPCNormalizationCrossesOrderedRegistryHandoff(t *testing.T) {
t.Fatalf("occurrence output step = %q, want ordered downstream step", occurrenceOutput.StepID)
}
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)
if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"name":"Mira Thorn"}]}` {
t.Fatalf("downstream registry = %s, want one canonical NPC name", got)
}
manifestContent, err := json.Marshal(output.Manifest)
if err != nil {