From 51d62de1f3a0f21fa29db86d5948fc3e72faede9 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 8 Aug 2026 14:56:43 +0000 Subject: [PATCH] Ground location occurrences with contextual selectors --- .../prompts/instructions.md | 5 + .../prompts/location-registry.md | 12 ++- .../dnd_location_occurrences_llm.v1.json | 15 ++- .../cli/dnd_enemy_events_contract_test.go | 20 ++-- .../locationoccurrences/canonicalize.go | 16 +++- .../extract/locationoccurrences/extractor.go | 16 +++- .../locationoccurrences/extractor_test.go | 92 ++++++++++++++----- .../dnd/extract/locationoccurrences/model.go | 10 +- .../locationoccurrences/prompt_assets_test.go | 8 +- .../locationoccurrences/schema_test.go | 5 +- .../dnd/locations/registry/registry.go | 69 +++++--------- .../dnd/locations/registry/registry_test.go | 30 ++---- .../dnd_location_registry_runner_test.go | 12 ++- 13 files changed, 181 insertions(+), 129 deletions(-) diff --git a/assets/dnd/location-occurrences/prompts/instructions.md b/assets/dnd/location-occurrences/prompts/instructions.md index 7cddd5d..b1e1aaa 100644 --- a/assets/dnd/location-occurrences/prompts/instructions.md +++ b/assets/dnd/location-occurrences/prompts/instructions.md @@ -20,6 +20,11 @@ location only when the chunk's context supports that coreference. It must not create a registry location, and registry content or provenance must never replace current-chunk evidence. +For every occurrence, return the exact selector from the location registry: +the canonical `name`, plus an empty `registry_refs` array for a unique name or +the complete ordered `registry_refs` array for a repeated name. Registry ranges +and context identify the location only; they are not occurrence evidence. + For overlapping support, visited outranks planned, recalled, and mentioned; planned outranks recalled and mentioned; recalled outranks mentioned. A passage may produce multiple records when it independently establishes separate facts, diff --git a/assets/dnd/location-occurrences/prompts/location-registry.md b/assets/dnd/location-occurrences/prompts/location-registry.md index bd84d73..971a81f 100644 --- a/assets/dnd/location-occurrences/prompts/location-registry.md +++ b/assets/dnd/location-occurrences/prompts/location-registry.md @@ -1,9 +1,11 @@ -A normalized location registry is provided below for identity grounding. It may -be empty. Each record contains the exact location ID and canonical display name -to copy when the transcript establishes an occurrence of that place. +A contextual location registry is provided below for identity grounding. It may +be empty. Every record supplies a canonical display name. A name that appears +once is selected with that name and an empty `registry_refs` array. A repeated +name is selected only by copying both its name and its complete, ordered +`registry_refs` array exactly as supplied. Registry content is context, not occurrence evidence. Do not derive an -occurrence or a source range from the registry, and do not infer a location -that is absent from it. +occurrence or `source_refs` range from the registry. Do not invent a location +or selector that is absent from it. {{ input "location_registry" }} diff --git a/assets/dnd/location-occurrences/schemas/dnd_location_occurrences_llm.v1.json b/assets/dnd/location-occurrences/schemas/dnd_location_occurrences_llm.v1.json index 05a8698..b848d70 100644 --- a/assets/dnd/location-occurrences/schemas/dnd_location_occurrences_llm.v1.json +++ b/assets/dnd/location-occurrences/schemas/dnd_location_occurrences_llm.v1.json @@ -10,10 +10,21 @@ "items": { "type": "object", "additionalProperties": false, - "required": ["location_id", "name", "kind", "source_refs"], + "required": ["name", "registry_refs", "kind", "source_refs"], "properties": { - "location_id": {"type": "string"}, "name": {"type": "string"}, + "registry_refs": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["start_unit_id", "end_unit_id"], + "properties": { + "start_unit_id": {"type": "integer", "minimum": 1}, + "end_unit_id": {"type": "integer", "minimum": 1} + } + } + }, "kind": {"enum": ["visited", "planned", "recalled", "mentioned"]}, "source_refs": { "type": "array", diff --git a/internal/cli/dnd_enemy_events_contract_test.go b/internal/cli/dnd_enemy_events_contract_test.go index 48942fb..90af2f8 100644 --- a/internal/cli/dnd_enemy_events_contract_test.go +++ b/internal/cli/dnd_enemy_events_contract_test.go @@ -219,8 +219,8 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te } for _, request := range locationRequests { registryInput := request.Inputs["location_registry"] - if !strings.Contains(string(registryInput.Content), "Moon Gate") || !strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") { - t.Fatalf("location occurrence registry input = %q, want source-free ID grounding", registryInput.Content) + if !strings.Contains(string(registryInput.Content), "Moon Gate") || !strings.Contains(string(registryInput.Content), "registry_refs") || strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") { + t.Fatalf("location occurrence registry input = %q, want contextual selector grounding", registryInput.Content) } } for _, test := range []struct { @@ -354,7 +354,11 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque case locationoccurrences.PromptID: var registry struct { Locations []struct { - ID string `json:"id"` + Name string `json:"name"` + RegistryRefs []struct { + StartUnitID int `json:"start_unit_id"` + EndUnitID int `json:"end_unit_id"` + } `json:"registry_refs"` } `json:"locations"` } if err := json.Unmarshal(request.Inputs["location_registry"].Content, ®istry); err != nil { @@ -364,14 +368,18 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has no locations") } unitID := 1 - locationID := registry.Locations[0].ID + location := registry.Locations[0] if combatScene { unitID = 7 if len(registry.Locations) > 1 { - locationID = registry.Locations[1].ID + location = registry.Locations[1] } } - content = []byte(fmt.Sprintf(`{"occurrences":[{"location_id":%q,"name":"Moon Gate","kind":"visited","source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, locationID, unitID, unitID)) + registryRefs, err := json.Marshal(location.RegistryRefs) + if err != nil { + return contracts.StructuredCompletionResponse{}, fmt.Errorf("encode location selector: %w", err) + } + content = []byte(fmt.Sprintf(`{"occurrences":[{"name":%q,"registry_refs":%s,"kind":"visited","source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, location.Name, registryRefs, unitID, unitID)) case enemyevents.PromptID: content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`) default: diff --git a/internal/modules/dnd/extract/locationoccurrences/canonicalize.go b/internal/modules/dnd/extract/locationoccurrences/canonicalize.go index b230dbb..5b086dc 100644 --- a/internal/modules/dnd/extract/locationoccurrences/canonicalize.go +++ b/internal/modules/dnd/extract/locationoccurrences/canonicalize.go @@ -1,11 +1,13 @@ package locationoccurrences import ( + "fmt" "reflect" "sort" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" ) @@ -15,17 +17,21 @@ type orderedOccurrence struct { hasEvidence bool } -func canonicalOccurrenceList(response extractionResponse, order shared.SourceRefOrder, sourceID string) dnd.LocationOccurrenceList { +func canonicalOccurrenceList(response extractionResponse, order shared.SourceRefOrder, sourceID string, grounding *locationregistry.Grounding) (dnd.LocationOccurrenceList, error) { if response.Occurrences == nil { - return dnd.LocationOccurrenceList{} + return dnd.LocationOccurrenceList{}, nil } ordered := make([]orderedOccurrence, len(response.Occurrences)) for index, occurrence := range response.Occurrences { + location, ok := grounding.Resolve(locationregistry.Selector{Name: occurrence.Name, RegistryRefs: occurrence.RegistryRefs}) + if !ok { + return dnd.LocationOccurrenceList{}, fmt.Errorf("occurrence %d does not match a supplied location selector", index) + } refs := order.Canonicalize(canonicalSourceRefs(occurrence.SourceRefs, sourceID)) earliest, hasEvidence := order.EarliestValid(refs) ordered[index] = orderedOccurrence{value: dnd.LocationOccurrence{ - LocationID: occurrence.LocationID, - Name: occurrence.Name, + LocationID: location.ID, + Name: location.Name, Kind: dnd.LocationOccurrenceKind(occurrence.Kind), SourceRefs: refs, }, earliest: earliest, hasEvidence: hasEvidence} @@ -39,7 +45,7 @@ func canonicalOccurrenceList(response extractionResponse, order shared.SourceRef occurrences = append(occurrences, occurrence.value) } } - return dnd.LocationOccurrenceList{Occurrences: occurrences} + return dnd.LocationOccurrenceList{Occurrences: occurrences}, nil } func lessOccurrence(left, right orderedOccurrence, order shared.SourceRefOrder) bool { diff --git a/internal/modules/dnd/extract/locationoccurrences/extractor.go b/internal/modules/dnd/extract/locationoccurrences/extractor.go index 61fce72..eaa1e56 100644 --- a/internal/modules/dnd/extract/locationoccurrences/extractor.go +++ b/internal/modules/dnd/extract/locationoccurrences/extractor.go @@ -15,7 +15,7 @@ import ( const ( Key = "dnd/location-occurrences" - mappingPolicy = "dnd.location_occurrences.extract_mapping.v1" + mappingPolicy = "dnd.location_occurrences.extract_mapping.v2" ) const ( @@ -121,7 +121,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint { {Name: "prompt", Value: e.promptSHA}, {Name: "response_schema", Value: e.responseSchemaSHA}, {Name: "mapping_policy", Value: mappingPolicy}, - {Name: "location_registry", Value: e.locationResolver.Seeded().ProjectionDigest()}, + {Name: "location_registry", Value: e.locationResolver.Seeded().IdentityDigest()}, } } @@ -143,17 +143,25 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe if !registry.Bound() { return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("location registry reference is required") } + grounding, err := locationregistry.NewGrounding(registry, req.Source) + if err != nil { + return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("prepare location grounding: %w", err) + } var response extractionResponse inputs := shared.PromptInputs(sourceInput, req.References) - inputs[LocationRegistryReferenceSlot] = registry.PromptInput() + inputs[LocationRegistryReferenceSlot] = grounding.PromptInput() if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs, }, &response); err != nil { return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("complete structured output: %w", err) } - return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{Value: canonicalOccurrenceList(response, shared.NewSourceRefOrder(req.Source), req.Source.ID)}, nil + occurrences, err := canonicalOccurrenceList(response, shared.NewSourceRefOrder(req.Source), req.Source.ID, grounding) + if err != nil { + return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("resolve location grounding: %w", err) + } + return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{Value: occurrences}, nil } func ModuleSpec() pipeline.ModuleSpec { diff --git a/internal/modules/dnd/extract/locationoccurrences/extractor_test.go b/internal/modules/dnd/extract/locationoccurrences/extractor_test.go index b02e95d..6a83fa1 100644 --- a/internal/modules/dnd/extract/locationoccurrences/extractor_test.go +++ b/internal/modules/dnd/extract/locationoccurrences/extractor_test.go @@ -14,19 +14,20 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" + locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry" ) func TestExtractMapsKindsOrdersOccurrencesAndPreservesIndependentFacts(t *testing.T) { locations := locationRegistry(t, "The Tavern", "The Tavern") first, second := locations.Locations[0], locations.Locations[1] client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{ - {LocationID: second.ID, Name: second.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30)}, - {LocationID: first.ID, Name: first.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}, - {LocationID: first.ID, Name: first.Name, Kind: "recalled", SourceRefs: occurrenceRefs(10, 10)}, - {LocationID: first.ID, Name: first.Name, Kind: "planned", SourceRefs: occurrenceRefs(10, 10)}, - {LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: append(occurrenceRefs(10, 10), occurrenceRefs(10, 10)...)}, - {LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: occurrenceRefs(20, 20)}, - {LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: occurrenceRefs(10, 10)}, + {Name: second.Name, RegistryRefs: registryRefs(second), Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30)}, + {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}, + {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "recalled", SourceRefs: occurrenceRefs(10, 10)}, + {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "planned", SourceRefs: occurrenceRefs(10, 10)}, + {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: append(occurrenceRefs(10, 10), occurrenceRefs(10, 10)...)}, + {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: occurrenceRefs(20, 20)}, + {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: occurrenceRefs(10, 10)}, }}} references := registryReferences(t, locations) req := extractionRequest() @@ -50,11 +51,11 @@ func TestExtractMapsKindsOrdersOccurrencesAndPreservesIndependentFacts(t *testin } } -func TestExtractUsesIDsNamesAndCurrentTranscriptEvidenceOnly(t *testing.T) { +func TestExtractResolvesContextualSelectorsAndUsesCurrentTranscriptEvidenceOnly(t *testing.T) { locations := locationRegistry(t, "The Tavern", "The Tavern") first, second := locations.Locations[0], locations.Locations[1] client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{ - LocationID: second.ID, Name: second.Name, Kind: "visited", SourceRefs: occurrenceRefs(10, 10), + Name: second.Name, RegistryRefs: registryRefs(second), Kind: "visited", SourceRefs: occurrenceRefs(10, 10), }}}} references := registryReferences(t, locations) req := extractionRequest() @@ -67,10 +68,10 @@ func TestExtractUsesIDsNamesAndCurrentTranscriptEvidenceOnly(t *testing.T) { t.Fatalf("occurrence = %#v", occurrence) } input := client.requests[0].Inputs[LocationRegistryReferenceSlot] - if input.Name != LocationRegistryReferenceSlot || !strings.Contains(string(input.Content), first.ID) || !strings.Contains(string(input.Content), second.ID) { + if input.Name != LocationRegistryReferenceSlot || !strings.Contains(string(input.Content), `"registry_refs":[{"start_unit_id":20,"end_unit_id":20}]`) { t.Fatalf("location prompt input = %#v", input) } - for _, forbidden := range []string{"source_refs", "source_id", "other-session"} { + for _, forbidden := range []string{"source_refs", "source_id", first.ID, second.ID} { if strings.Contains(string(input.Content), forbidden) { t.Fatalf("location prompt leaked %q: %s", forbidden, input.Content) } @@ -84,22 +85,46 @@ func TestExtractUsesIDsNamesAndCurrentTranscriptEvidenceOnly(t *testing.T) { } } -func TestExtractPreservesUnknownOrMismatchedGroundingForValidators(t *testing.T) { +func TestExtractRejectsUnknownMalformedOrMismatchedSelectorsAtomically(t *testing.T) { locations := locationRegistry(t, "The Mill") known := locations.Locations[0] - client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{ - {LocationID: "location:sha256:unknown", Name: "The Mill", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}, - {LocationID: known.ID, Name: "A Different Mill", Kind: "mentioned", SourceRefs: occurrenceRefs(20, 20)}, - }}} references := registryReferences(t, locations) req := extractionRequest() req.References = references - result, err := newExtractor(t, client, references).Extract(context.Background(), req) - if err != nil { - t.Fatal(err) + for _, occurrences := range [][]occurrenceResponse{ + {{Name: "Unknown", RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}}, + {{Name: known.Name, RegistryRefs: []locationregistry.RegistryRef{{StartUnitID: 10, EndUnitID: 0}}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}}, + {{Name: known.Name, RegistryRefs: []locationregistry.RegistryRef{{StartUnitID: 10, EndUnitID: 10}}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}}, + {{Name: known.Name, RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}, {Name: "Unknown", RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(20, 20)}}, + } { + client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: occurrences}} + if result, err := newExtractor(t, client, references).Extract(context.Background(), req); err == nil || result.Value.Occurrences != nil || !strings.Contains(err.Error(), "location selector") { + t.Fatalf("Extract() = %#v, %v", result, err) + } } - if result.Value.Occurrences[0].LocationID != "location:sha256:unknown" || result.Value.Occurrences[1].Name != "A Different Mill" { - t.Fatalf("extractor repaired validator-owned grounding errors: %#v", result.Value.Occurrences) + + sharedName := "The Tavern" + firstRefs := []source.SourceRef{{SourceID: req.Source.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: req.Source.ID, StartUnitID: 20, EndUnitID: 20}} + secondRefs := []source.SourceRef{{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 30}} + duplicateLocations := dnd.LocationRegistry{Locations: []dnd.Location{ + {ID: identity.DeriveID(sharedName, firstRefs), Name: sharedName, SourceRefs: firstRefs}, + {ID: identity.DeriveID(sharedName, secondRefs), Name: sharedName, SourceRefs: secondRefs}, + }} + duplicateReferences := registryReferences(t, duplicateLocations) + for _, selector := range []struct { + name string + refs []locationregistry.RegistryRef + }{ + {name: sharedName, refs: []locationregistry.RegistryRef{}}, + {name: sharedName, refs: []locationregistry.RegistryRef{{StartUnitID: 10, EndUnitID: 10}}}, + {name: sharedName, refs: []locationregistry.RegistryRef{{StartUnitID: 20, EndUnitID: 20}, {StartUnitID: 10, EndUnitID: 10}}}, + } { + client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{Name: selector.name, RegistryRefs: selector.refs, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}}}} + duplicateRequest := extractionRequest() + duplicateRequest.References = duplicateReferences + if _, err := newExtractor(t, client, duplicateReferences).Extract(context.Background(), duplicateRequest); err == nil || !strings.Contains(err.Error(), "location selector") { + t.Fatalf("Extract(%#v) error = %v", selector, err) + } } } @@ -125,7 +150,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTimeAndDoesNotMutateResponse locations := locationRegistry(t, "The Mill") location := locations.Locations[0] client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{ - LocationID: location.ID, Name: location.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30), + Name: location.Name, RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30), }}}} references := registryReferences(t, locations) req := extractionRequest() @@ -135,7 +160,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTimeAndDoesNotMutateResponse if err != nil || result.Value.Occurrences[0].Name != "The Mill" { t.Fatalf("Extract() = %#v, %v", result, err) } - if input := client.requests[0].Inputs[LocationRegistryReferenceSlot]; !strings.Contains(string(input.Content), location.ID) || input.OriginURI != "" { + if input := client.requests[0].Inputs[LocationRegistryReferenceSlot]; strings.Contains(string(input.Content), location.ID) || input.OriginURI != "" { t.Fatalf("generated registry prompt input = %#v", input) } if _, ok := extractor.ManifestMetadata()["location_registry_digest"]; ok { @@ -215,21 +240,38 @@ func TestExtractorContractsMetadataAndFailures(t *testing.T) { t.Fatalf("metadata[%q] = %#v", key, metadata[key]) } } - if got := newExtractor(t, &fakeOccurrencesLLMClient{}, references).CheckpointFingerprints(); len(got) != 4 || got[3].Name != "location_registry" { + if got := newExtractor(t, &fakeOccurrencesLLMClient{}, references).CheckpointFingerprints(); len(got) != 4 || got[3].Name != "location_registry" || got[3].Value != locationRegistryIdentityDigest(t, references) { t.Fatalf("fingerprints = %#v", got) } } +func locationRegistryIdentityDigest(t *testing.T, references contracts.ReferenceSet) string { + t.Helper() + resolver, err := locationregistry.NewResolver(references) + if err != nil { + t.Fatal(err) + } + return resolver.Seeded().IdentityDigest() +} + func locationRegistry(t *testing.T, names ...string) dnd.LocationRegistry { t.Helper() locations := make([]dnd.Location, len(names)) for index, name := range names { - refs := []source.SourceRef{{SourceID: "other-session", StartUnitID: index + 1, EndUnitID: index + 1}} + refs := []source.SourceRef{{SourceID: sourceDocument().ID, StartUnitID: (index + 1) * 10, EndUnitID: (index + 1) * 10}} locations[index] = dnd.Location{ID: identity.DeriveID(name, refs), Name: name, SourceRefs: refs} } return dnd.LocationRegistry{Locations: locations} } +func registryRefs(location dnd.Location) []locationregistry.RegistryRef { + refs := make([]locationregistry.RegistryRef, len(location.SourceRefs)) + for index, ref := range location.SourceRefs { + refs[index] = locationregistry.RegistryRef{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID} + } + return refs +} + func registryReferences(t *testing.T, locations dnd.LocationRegistry) contracts.ReferenceSet { t.Helper() content, err := locationcodec.New().Encode(locations) diff --git a/internal/modules/dnd/extract/locationoccurrences/model.go b/internal/modules/dnd/extract/locationoccurrences/model.go index 42d9db2..261f11c 100644 --- a/internal/modules/dnd/extract/locationoccurrences/model.go +++ b/internal/modules/dnd/extract/locationoccurrences/model.go @@ -1,14 +1,16 @@ package locationoccurrences +import locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry" + type extractionResponse struct { Occurrences []occurrenceResponse `json:"occurrences"` } type occurrenceResponse struct { - LocationID string `json:"location_id"` - Name string `json:"name"` - Kind string `json:"kind"` - SourceRefs []occurrenceSourceRefResponse `json:"source_refs"` + Name string `json:"name"` + RegistryRefs []locationregistry.RegistryRef `json:"registry_refs"` + Kind string `json:"kind"` + SourceRefs []occurrenceSourceRefResponse `json:"source_refs"` } type occurrenceSourceRefResponse struct { diff --git a/internal/modules/dnd/extract/locationoccurrences/prompt_assets_test.go b/internal/modules/dnd/extract/locationoccurrences/prompt_assets_test.go index 731eea0..142d7a8 100644 --- a/internal/modules/dnd/extract/locationoccurrences/prompt_assets_test.go +++ b/internal/modules/dnd/extract/locationoccurrences/prompt_assets_test.go @@ -30,7 +30,7 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) { PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "location-occurrences-test", Inputs: map[string]promptkit.ArtifactRef{ "transcript": promptkit.Inline(`{"units":[{"sentinel":"location-occurrence-transcript"}]}`), "players": promptkit.Inline("location-occurrence-player"), "party": promptkit.Inline(" "), "glossary": promptkit.Inline(" "), - "location_registry": promptkit.Inline(`{"locations":[{"id":"location:sha256:test","name":"location-occurrence-registry"}]}`), + "location_registry": promptkit.Inline(`{"locations":[{"name":"location-occurrence-registry","registry_refs":[{"start_unit_id":7,"end_unit_id":7}],"context":[{"unit_id":7,"text":"registry context"}]}]}`), }, }) if err != nil { @@ -41,11 +41,11 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) { } var registryMessage string for _, message := range prepared.Messages { - if strings.Contains(message.Content, "normalized location registry") { + if strings.Contains(message.Content, "contextual location registry") { registryMessage = message.Content } } - if !strings.Contains(registryMessage, "location:sha256:test") || !strings.Contains(registryMessage, "location-occurrence-registry") || strings.Contains(registryMessage, "source_refs") { + if !strings.Contains(registryMessage, "location-occurrence-registry") || !strings.Contains(registryMessage, `"registry_refs":[{"start_unit_id":7,"end_unit_id":7}]`) || strings.Contains(registryMessage, "source_id") || strings.Contains(registryMessage, "location:sha256") { t.Fatalf("rendered prompt did not preserve source-free registry grounding: %s", registryMessage) } content := make([]string, len(prepared.Messages)) @@ -54,7 +54,7 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) { } rendered := strings.Join(content, "\n") policy := strings.ReplaceAll(rendered, "\n", " ") - if !strings.Contains(policy, "context supports that coreference") || !strings.Contains(policy, "must not create a registry location") || !strings.Contains(policy, "provenance must never replace current-chunk evidence") { + if !strings.Contains(policy, "context supports that coreference") || !strings.Contains(policy, "must not create a registry location") || !strings.Contains(policy, "provenance must never replace current-chunk evidence") || !strings.Contains(policy, "complete, ordered") { t.Fatalf("rendered prompt = %q, want contextual coreference without registry-derived evidence", rendered) } } diff --git a/internal/modules/dnd/extract/locationoccurrences/schema_test.go b/internal/modules/dnd/extract/locationoccurrences/schema_test.go index 30a424f..12d7c98 100644 --- a/internal/modules/dnd/extract/locationoccurrences/schema_test.go +++ b/internal/modules/dnd/extract/locationoccurrences/schema_test.go @@ -18,16 +18,17 @@ func TestResponseSchemaRestrictsPrivateOccurrenceStructureAndKinds(t *testing.T) t.Fatalf("schema = %#v", schema) } valid := map[string]any{"occurrences": []any{map[string]any{ - "location_id": "location:sha256:test", "name": "The Mill", "kind": "visited", + "name": "The Mill", "registry_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, "kind": "visited", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, }}} if err := validateSchema(t, valid, schema.JSONSchema); err != nil { t.Fatalf("valid response rejected: %v", err) } for _, mutate := range []func(map[string]any){ - func(value map[string]any) { delete(value, "location_id") }, + func(value map[string]any) { delete(value, "registry_refs") }, func(value map[string]any) { value["kind"] = "other" }, func(value map[string]any) { value["unexpected"] = true }, + func(value map[string]any) { value["registry_refs"].([]any)[0].(map[string]any)["start_unit_id"] = 0 }, func(value map[string]any) { value["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later" }, diff --git a/internal/modules/dnd/locations/registry/registry.go b/internal/modules/dnd/locations/registry/registry.go index 737da44..b81b372 100644 --- a/internal/modules/dnd/locations/registry/registry.go +++ b/internal/modules/dnd/locations/registry/registry.go @@ -18,22 +18,20 @@ import ( ) const ( - ReferenceSlot = "location_registry" - MaxBytes = 1048576 - emptyPrompt = `{"locations":[]}` + ReferenceSlot = "location_registry" + MaxBytes = 1048576 + emptyRegistryContent = `{"locations":[]}` ) // Registry is an immutable, validated location registry prepared for prompt // grounding. All accessors return defensive copies. type Registry struct { - bound bool - list dnd.LocationRegistry - canonical []byte - digest string - projectionDigest string - identityDigest string - promptInput contracts.LLMInputMaterial - lookupByID map[string]int + bound bool + list dnd.LocationRegistry + canonical []byte + digest string + identityDigest string + lookupByID map[string]int } // Resolver selects and memoizes immutable location registry views. @@ -100,15 +98,13 @@ func locationReferenceSpec() registryresolver.ReferenceSpec { } func emptyRegistry() *Registry { - content := []byte(emptyPrompt) - projectionDigest := semanticDigest(content) + content := []byte(emptyRegistryContent) + identityDigest := semanticDigest(content) return &Registry{ - list: dnd.LocationRegistry{Locations: []dnd.Location{}}, - canonical: append([]byte(nil), content...), - projectionDigest: projectionDigest, - identityDigest: projectionDigest, - promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, content, projectionDigest, ""), - lookupByID: map[string]int{}, + list: dnd.LocationRegistry{Locations: []dnd.Location{}}, + canonical: append([]byte(nil), content...), + identityDigest: identityDigest, + lookupByID: map[string]int{}, } } @@ -136,16 +132,14 @@ func loadRegistry(referenceContent []byte) (*Registry, error) { return nil, fmt.Errorf("encode location prompt projection: %w", err) } digest := semanticDigest(content) - projectionDigest := semanticDigest(projection) + identityDigest := semanticDigest(projection) return &Registry{ - bound: true, - list: list, - canonical: append([]byte(nil), content...), - digest: digest, - projectionDigest: projectionDigest, - identityDigest: projectionDigest, - promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, projection, projectionDigest, ""), - lookupByID: lookupByID, + bound: true, + list: list, + canonical: append([]byte(nil), content...), + digest: digest, + identityDigest: identityDigest, + lookupByID: lookupByID, }, nil } @@ -185,15 +179,6 @@ func (r *Registry) Digest() string { return r.digest } -// ProjectionDigest returns the digest of the exact source-free prompt -// projection, including for an unbound or empty registry. -func (r *Registry) ProjectionDigest() string { - if r == nil { - return "" - } - return r.projectionDigest -} - // IdentityDigest returns the digest of the ordered ID/name identity projection // used by deterministic consumers. func (r *Registry) IdentityDigest() string { @@ -211,16 +196,6 @@ func (r *Registry) Count() int { return len(r.list.Locations) } -// PromptInput returns the ordered ID-and-name projection without evidence or -// reference provenance. It remains available only while the location occurrence -// extractor transitions to contextual grounding. -func (r *Registry) PromptInput() contracts.LLMInputMaterial { - if r == nil { - return contracts.LLMInputMaterial{} - } - return r.promptInput.Clone() -} - // Lookup returns the canonical location for an exact durable location ID. func (r *Registry) Lookup(id string) (dnd.Location, bool) { if r == nil { diff --git a/internal/modules/dnd/locations/registry/registry_test.go b/internal/modules/dnd/locations/registry/registry_test.go index ea185ce..e081837 100644 --- a/internal/modules/dnd/locations/registry/registry_test.go +++ b/internal/modules/dnd/locations/registry/registry_test.go @@ -14,36 +14,26 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" ) -func TestResolveUnboundRegistryHasEmptyProjection(t *testing.T) { +func TestResolveUnboundRegistryHasEmptyIdentity(t *testing.T) { registry, err := Resolve(contracts.ReferenceSet{}) if err != nil { t.Fatal(err) } - input := registry.PromptInput() - if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt { - t.Fatalf("unbound registry = %#v, input = %#v", registry, input) + if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(registry.CanonicalBytes()) != emptyRegistryContent { + t.Fatalf("unbound registry = %#v", registry) } - if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" { - t.Fatalf("projection digest/input = %q/%#v", registry.ProjectionDigest(), input) + if registry.IdentityDigest() == "" { + t.Fatal("unbound registry identity digest is empty") } } -func TestResolveProjectsOrderedLocationsWithoutEvidence(t *testing.T) { +func TestResolveRetainsSeparateDurableAndIdentityDigests(t *testing.T) { registry := resolveList(t, registryFixture()) if !registry.Bound() || registry.Count() != 2 || registry.Digest() == "" { t.Fatalf("registry identity = bound %t count %d digest %q", registry.Bound(), registry.Count(), registry.Digest()) } - projection := string(registry.PromptInput().Content) - if !strings.Contains(projection, `"locations":[{"id":`) || !strings.Contains(projection, `"name":"The Tavern"`) || !strings.Contains(projection, `"name":"The Tavern"},{"id":`) { - t.Fatalf("projection ordering = %s", projection) - } - for _, forbidden := range []string{"source_refs", "source_id", "session-alpha"} { - if strings.Contains(projection, forbidden) { - t.Fatalf("projection leaked %q: %s", forbidden, projection) - } - } - if registry.PromptInput().Digest != registry.ProjectionDigest() || registry.Digest() == registry.ProjectionDigest() { - t.Fatalf("full/projection digests = %q/%q", registry.Digest(), registry.ProjectionDigest()) + if registry.IdentityDigest() == "" || registry.Digest() == registry.IdentityDigest() { + t.Fatalf("full/identity digests = %q/%q", registry.Digest(), registry.IdentityDigest()) } } @@ -62,12 +52,10 @@ func TestRegistryLookupUsesIDAndReturnsDefensiveCopies(t *testing.T) { locations[0].SourceRefs[0].SourceID = "changed" canonical := registry.CanonicalBytes() canonical[0] = '[' - input := registry.PromptInput() - input.Content[0] = '[' if next, ok := registry.Lookup(first.ID); !ok || next.Name != first.Name || next.SourceRefs[0].SourceID != "session-alpha" { t.Fatalf("registry mutated through accessor: %#v, %t", next, ok) } - if registry.CanonicalBytes()[0] != '{' || registry.PromptInput().Content[0] != '{' { + if registry.CanonicalBytes()[0] != '{' { t.Fatal("registry bytes mutated through accessor") } } diff --git a/internal/modules/integration/dnd_location_registry_runner_test.go b/internal/modules/integration/dnd_location_registry_runner_test.go index 954515b..86e03d2 100644 --- a/internal/modules/integration/dnd_location_registry_runner_test.go +++ b/internal/modules/integration/dnd_location_registry_runner_test.go @@ -63,8 +63,8 @@ func TestLocationRegistryHandoffProducesOccurrencesAndEvidence(t *testing.T) { request := client.requestFor(t, locationoccurrences.PromptID) registryInput := request.Inputs["location_registry"] - if registryInput.MediaType != locationcodec.MediaType || strings.Contains(string(registryInput.Content), "source_refs") || !strings.Contains(string(registryInput.Content), registry.Locations[0].ID) || !strings.Contains(string(registryInput.Content), registry.Locations[1].ID) { - t.Fatalf("occurrence registry input = %#v, want source-free generated ID projection", registryInput) + if registryInput.MediaType != locationcodec.MediaType || strings.Contains(string(registryInput.Content), "source_refs") || strings.Contains(string(registryInput.Content), registry.Locations[0].ID) || strings.Contains(string(registryInput.Content), registry.Locations[1].ID) || !strings.Contains(string(registryInput.Content), "registry_refs") { + t.Fatalf("occurrence registry input = %#v, want contextual selector projection", registryInput) } contextArtifact := outputFileContent(t, output.OutputFiles, "evidence-context.json") evidence, err := evidencecontext.New().Decode(contextArtifact) @@ -218,7 +218,11 @@ func (client *locationHandoffLLMClient) CompleteStructured(ctx context.Context, case locationoccurrences.PromptID: var projection struct { Locations []struct { - ID string `json:"id"` + Name string `json:"name"` + RegistryRefs []struct { + StartUnitID int `json:"start_unit_id"` + EndUnitID int `json:"end_unit_id"` + } `json:"registry_refs"` } `json:"locations"` } if err := json.Unmarshal(request.Inputs["location_registry"].Content, &projection); err != nil { @@ -227,7 +231,7 @@ func (client *locationHandoffLLMClient) CompleteStructured(ctx context.Context, if len(projection.Locations) != 2 { return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has %d locations, want 2", len(projection.Locations)) } - payload = map[string]any{"occurrences": []any{map[string]any{"location_id": projection.Locations[0].ID, "name": "Moon Gate", "kind": "visited", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}, map[string]any{"location_id": projection.Locations[1].ID, "name": "Moon Gate", "kind": "mentioned", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}}}} + payload = map[string]any{"occurrences": []any{map[string]any{"name": projection.Locations[0].Name, "registry_refs": projection.Locations[0].RegistryRefs, "kind": "visited", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}, map[string]any{"name": projection.Locations[1].Name, "registry_refs": projection.Locations[1].RegistryRefs, "kind": "mentioned", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}}}} default: return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID) }