Ground location occurrences with contextual selectors

This commit is contained in:
2026-08-08 14:56:43 +00:00
parent fc76805075
commit 51d62de1f3
13 changed files with 181 additions and 129 deletions

View File

@@ -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 create a registry location, and registry content or provenance must never
replace current-chunk evidence. 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; For overlapping support, visited outranks planned, recalled, and mentioned;
planned outranks recalled and mentioned; recalled outranks mentioned. A passage planned outranks recalled and mentioned; recalled outranks mentioned. A passage
may produce multiple records when it independently establishes separate facts, may produce multiple records when it independently establishes separate facts,

View File

@@ -1,9 +1,11 @@
A normalized location registry is provided below for identity grounding. It may A contextual location registry is provided below for identity grounding. It may
be empty. Each record contains the exact location ID and canonical display name be empty. Every record supplies a canonical display name. A name that appears
to copy when the transcript establishes an occurrence of that place. 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 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 occurrence or `source_refs` range from the registry. Do not invent a location
that is absent from it. or selector that is absent from it.
{{ input "location_registry" }} {{ input "location_registry" }}

View File

@@ -10,10 +10,21 @@
"items": { "items": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": ["location_id", "name", "kind", "source_refs"], "required": ["name", "registry_refs", "kind", "source_refs"],
"properties": { "properties": {
"location_id": {"type": "string"},
"name": {"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"]}, "kind": {"enum": ["visited", "planned", "recalled", "mentioned"]},
"source_refs": { "source_refs": {
"type": "array", "type": "array",

View File

@@ -219,8 +219,8 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
} }
for _, request := range locationRequests { for _, request := range locationRequests {
registryInput := request.Inputs["location_registry"] 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") { 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 source-free ID grounding", registryInput.Content) t.Fatalf("location occurrence registry input = %q, want contextual selector grounding", registryInput.Content)
} }
} }
for _, test := range []struct { for _, test := range []struct {
@@ -354,7 +354,11 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
case locationoccurrences.PromptID: case locationoccurrences.PromptID:
var registry struct { var registry struct {
Locations []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"` } `json:"locations"`
} }
if err := json.Unmarshal(request.Inputs["location_registry"].Content, &registry); err != nil { if err := json.Unmarshal(request.Inputs["location_registry"].Content, &registry); 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") return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has no locations")
} }
unitID := 1 unitID := 1
locationID := registry.Locations[0].ID location := registry.Locations[0]
if combatScene { if combatScene {
unitID = 7 unitID = 7
if len(registry.Locations) > 1 { 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: case enemyevents.PromptID:
content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`) content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`)
default: default:

View File

@@ -1,11 +1,13 @@
package locationoccurrences package locationoccurrences
import ( import (
"fmt"
"reflect" "reflect"
"sort" "sort"
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
@@ -15,17 +17,21 @@ type orderedOccurrence struct {
hasEvidence bool 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 { if response.Occurrences == nil {
return dnd.LocationOccurrenceList{} return dnd.LocationOccurrenceList{}, nil
} }
ordered := make([]orderedOccurrence, len(response.Occurrences)) ordered := make([]orderedOccurrence, len(response.Occurrences))
for index, occurrence := range 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)) refs := order.Canonicalize(canonicalSourceRefs(occurrence.SourceRefs, sourceID))
earliest, hasEvidence := order.EarliestValid(refs) earliest, hasEvidence := order.EarliestValid(refs)
ordered[index] = orderedOccurrence{value: dnd.LocationOccurrence{ ordered[index] = orderedOccurrence{value: dnd.LocationOccurrence{
LocationID: occurrence.LocationID, LocationID: location.ID,
Name: occurrence.Name, Name: location.Name,
Kind: dnd.LocationOccurrenceKind(occurrence.Kind), Kind: dnd.LocationOccurrenceKind(occurrence.Kind),
SourceRefs: refs, SourceRefs: refs,
}, earliest: earliest, hasEvidence: hasEvidence} }, earliest: earliest, hasEvidence: hasEvidence}
@@ -39,7 +45,7 @@ func canonicalOccurrenceList(response extractionResponse, order shared.SourceRef
occurrences = append(occurrences, occurrence.value) 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 { func lessOccurrence(left, right orderedOccurrence, order shared.SourceRefOrder) bool {

View File

@@ -15,7 +15,7 @@ import (
const ( const (
Key = "dnd/location-occurrences" Key = "dnd/location-occurrences"
mappingPolicy = "dnd.location_occurrences.extract_mapping.v1" mappingPolicy = "dnd.location_occurrences.extract_mapping.v2"
) )
const ( const (
@@ -121,7 +121,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "prompt", Value: e.promptSHA}, {Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA}, {Name: "response_schema", Value: e.responseSchemaSHA},
{Name: "mapping_policy", Value: mappingPolicy}, {Name: "mapping_policy", Value: mappingPolicy},
{Name: "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() { if !registry.Bound() {
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("location registry reference is required") 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 var response extractionResponse
inputs := shared.PromptInputs(sourceInput, req.References) inputs := shared.PromptInputs(sourceInput, req.References)
inputs[LocationRegistryReferenceSlot] = registry.PromptInput() inputs[LocationRegistryReferenceSlot] = grounding.PromptInput()
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion, StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion,
ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs, ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs,
}, &response); err != nil { }, &response); err != nil {
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("complete structured output: %w", err) 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 { func ModuleSpec() pipeline.ModuleSpec {

View File

@@ -14,19 +14,20 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry" locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" "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) { func TestExtractMapsKindsOrdersOccurrencesAndPreservesIndependentFacts(t *testing.T) {
locations := locationRegistry(t, "The Tavern", "The Tavern") locations := locationRegistry(t, "The Tavern", "The Tavern")
first, second := locations.Locations[0], locations.Locations[1] first, second := locations.Locations[0], locations.Locations[1]
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{ client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
{LocationID: second.ID, Name: second.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30)}, {Name: second.Name, RegistryRefs: registryRefs(second), Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30)},
{LocationID: first.ID, Name: first.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}, {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
{LocationID: first.ID, Name: first.Name, Kind: "recalled", SourceRefs: occurrenceRefs(10, 10)}, {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "recalled", SourceRefs: occurrenceRefs(10, 10)},
{LocationID: first.ID, Name: first.Name, Kind: "planned", SourceRefs: occurrenceRefs(10, 10)}, {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "planned", SourceRefs: occurrenceRefs(10, 10)},
{LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: append(occurrenceRefs(10, 10), occurrenceRefs(10, 10)...)}, {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: append(occurrenceRefs(10, 10), occurrenceRefs(10, 10)...)},
{LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: occurrenceRefs(20, 20)}, {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: occurrenceRefs(20, 20)},
{LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: occurrenceRefs(10, 10)}, {Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: occurrenceRefs(10, 10)},
}}} }}}
references := registryReferences(t, locations) references := registryReferences(t, locations)
req := extractionRequest() 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") locations := locationRegistry(t, "The Tavern", "The Tavern")
first, second := locations.Locations[0], locations.Locations[1] first, second := locations.Locations[0], locations.Locations[1]
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{ 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) references := registryReferences(t, locations)
req := extractionRequest() req := extractionRequest()
@@ -67,10 +68,10 @@ func TestExtractUsesIDsNamesAndCurrentTranscriptEvidenceOnly(t *testing.T) {
t.Fatalf("occurrence = %#v", occurrence) t.Fatalf("occurrence = %#v", occurrence)
} }
input := client.requests[0].Inputs[LocationRegistryReferenceSlot] 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) 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) { if strings.Contains(string(input.Content), forbidden) {
t.Fatalf("location prompt leaked %q: %s", forbidden, input.Content) 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") locations := locationRegistry(t, "The Mill")
known := locations.Locations[0] 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) references := registryReferences(t, locations)
req := extractionRequest() req := extractionRequest()
req.References = references req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req) for _, occurrences := range [][]occurrenceResponse{
if err != nil { {{Name: "Unknown", RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}},
t.Fatal(err) {{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") locations := locationRegistry(t, "The Mill")
location := locations.Locations[0] location := locations.Locations[0]
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{ 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) references := registryReferences(t, locations)
req := extractionRequest() req := extractionRequest()
@@ -135,7 +160,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTimeAndDoesNotMutateResponse
if err != nil || result.Value.Occurrences[0].Name != "The Mill" { if err != nil || result.Value.Occurrences[0].Name != "The Mill" {
t.Fatalf("Extract() = %#v, %v", result, err) 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) t.Fatalf("generated registry prompt input = %#v", input)
} }
if _, ok := extractor.ManifestMetadata()["location_registry_digest"]; ok { 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]) 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) 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 { func locationRegistry(t *testing.T, names ...string) dnd.LocationRegistry {
t.Helper() t.Helper()
locations := make([]dnd.Location, len(names)) locations := make([]dnd.Location, len(names))
for index, name := range 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} locations[index] = dnd.Location{ID: identity.DeriveID(name, refs), Name: name, SourceRefs: refs}
} }
return dnd.LocationRegistry{Locations: locations} 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 { func registryReferences(t *testing.T, locations dnd.LocationRegistry) contracts.ReferenceSet {
t.Helper() t.Helper()
content, err := locationcodec.New().Encode(locations) content, err := locationcodec.New().Encode(locations)

View File

@@ -1,14 +1,16 @@
package locationoccurrences package locationoccurrences
import locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry"
type extractionResponse struct { type extractionResponse struct {
Occurrences []occurrenceResponse `json:"occurrences"` Occurrences []occurrenceResponse `json:"occurrences"`
} }
type occurrenceResponse struct { type occurrenceResponse struct {
LocationID string `json:"location_id"` Name string `json:"name"`
Name string `json:"name"` RegistryRefs []locationregistry.RegistryRef `json:"registry_refs"`
Kind string `json:"kind"` Kind string `json:"kind"`
SourceRefs []occurrenceSourceRefResponse `json:"source_refs"` SourceRefs []occurrenceSourceRefResponse `json:"source_refs"`
} }
type occurrenceSourceRefResponse struct { type occurrenceSourceRefResponse struct {

View File

@@ -30,7 +30,7 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) {
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "location-occurrences-test", PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "location-occurrences-test",
Inputs: map[string]promptkit.ArtifactRef{ 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(" "), "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 { if err != nil {
@@ -41,11 +41,11 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) {
} }
var registryMessage string var registryMessage string
for _, message := range prepared.Messages { for _, message := range prepared.Messages {
if strings.Contains(message.Content, "normalized location registry") { if strings.Contains(message.Content, "contextual location registry") {
registryMessage = message.Content 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) t.Fatalf("rendered prompt did not preserve source-free registry grounding: %s", registryMessage)
} }
content := make([]string, len(prepared.Messages)) content := make([]string, len(prepared.Messages))
@@ -54,7 +54,7 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) {
} }
rendered := strings.Join(content, "\n") rendered := strings.Join(content, "\n")
policy := strings.ReplaceAll(rendered, "\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) t.Fatalf("rendered prompt = %q, want contextual coreference without registry-derived evidence", rendered)
} }
} }

View File

@@ -18,16 +18,17 @@ func TestResponseSchemaRestrictsPrivateOccurrenceStructureAndKinds(t *testing.T)
t.Fatalf("schema = %#v", schema) t.Fatalf("schema = %#v", schema)
} }
valid := map[string]any{"occurrences": []any{map[string]any{ 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}}, "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
}}} }}}
if err := validateSchema(t, valid, schema.JSONSchema); err != nil { if err := validateSchema(t, valid, schema.JSONSchema); err != nil {
t.Fatalf("valid response rejected: %v", err) t.Fatalf("valid response rejected: %v", err)
} }
for _, mutate := range []func(map[string]any){ 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["kind"] = "other" },
func(value map[string]any) { value["unexpected"] = true }, 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) { func(value map[string]any) {
value["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later" value["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later"
}, },

View File

@@ -18,22 +18,20 @@ import (
) )
const ( const (
ReferenceSlot = "location_registry" ReferenceSlot = "location_registry"
MaxBytes = 1048576 MaxBytes = 1048576
emptyPrompt = `{"locations":[]}` emptyRegistryContent = `{"locations":[]}`
) )
// Registry is an immutable, validated location registry prepared for prompt // Registry is an immutable, validated location registry prepared for prompt
// grounding. All accessors return defensive copies. // grounding. All accessors return defensive copies.
type Registry struct { type Registry struct {
bound bool bound bool
list dnd.LocationRegistry list dnd.LocationRegistry
canonical []byte canonical []byte
digest string digest string
projectionDigest string identityDigest string
identityDigest string lookupByID map[string]int
promptInput contracts.LLMInputMaterial
lookupByID map[string]int
} }
// Resolver selects and memoizes immutable location registry views. // Resolver selects and memoizes immutable location registry views.
@@ -100,15 +98,13 @@ func locationReferenceSpec() registryresolver.ReferenceSpec {
} }
func emptyRegistry() *Registry { func emptyRegistry() *Registry {
content := []byte(emptyPrompt) content := []byte(emptyRegistryContent)
projectionDigest := semanticDigest(content) identityDigest := semanticDigest(content)
return &Registry{ return &Registry{
list: dnd.LocationRegistry{Locations: []dnd.Location{}}, list: dnd.LocationRegistry{Locations: []dnd.Location{}},
canonical: append([]byte(nil), content...), canonical: append([]byte(nil), content...),
projectionDigest: projectionDigest, identityDigest: identityDigest,
identityDigest: projectionDigest, lookupByID: map[string]int{},
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, content, projectionDigest, ""),
lookupByID: map[string]int{},
} }
} }
@@ -136,16 +132,14 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
return nil, fmt.Errorf("encode location prompt projection: %w", err) return nil, fmt.Errorf("encode location prompt projection: %w", err)
} }
digest := semanticDigest(content) digest := semanticDigest(content)
projectionDigest := semanticDigest(projection) identityDigest := semanticDigest(projection)
return &Registry{ return &Registry{
bound: true, bound: true,
list: list, list: list,
canonical: append([]byte(nil), content...), canonical: append([]byte(nil), content...),
digest: digest, digest: digest,
projectionDigest: projectionDigest, identityDigest: identityDigest,
identityDigest: projectionDigest, lookupByID: lookupByID,
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, projection, projectionDigest, ""),
lookupByID: lookupByID,
}, nil }, nil
} }
@@ -185,15 +179,6 @@ func (r *Registry) Digest() string {
return r.digest 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 // IdentityDigest returns the digest of the ordered ID/name identity projection
// used by deterministic consumers. // used by deterministic consumers.
func (r *Registry) IdentityDigest() string { func (r *Registry) IdentityDigest() string {
@@ -211,16 +196,6 @@ func (r *Registry) Count() int {
return len(r.list.Locations) 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. // Lookup returns the canonical location for an exact durable location ID.
func (r *Registry) Lookup(id string) (dnd.Location, bool) { func (r *Registry) Lookup(id string) (dnd.Location, bool) {
if r == nil { if r == nil {

View File

@@ -14,36 +14,26 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" "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{}) registry, err := Resolve(contracts.ReferenceSet{})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
input := registry.PromptInput() if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(registry.CanonicalBytes()) != emptyRegistryContent {
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt { t.Fatalf("unbound registry = %#v", registry)
t.Fatalf("unbound registry = %#v, input = %#v", registry, input)
} }
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" { if registry.IdentityDigest() == "" {
t.Fatalf("projection digest/input = %q/%#v", registry.ProjectionDigest(), input) t.Fatal("unbound registry identity digest is empty")
} }
} }
func TestResolveProjectsOrderedLocationsWithoutEvidence(t *testing.T) { func TestResolveRetainsSeparateDurableAndIdentityDigests(t *testing.T) {
registry := resolveList(t, registryFixture()) registry := resolveList(t, registryFixture())
if !registry.Bound() || registry.Count() != 2 || registry.Digest() == "" { if !registry.Bound() || registry.Count() != 2 || registry.Digest() == "" {
t.Fatalf("registry identity = bound %t count %d digest %q", registry.Bound(), registry.Count(), registry.Digest()) t.Fatalf("registry identity = bound %t count %d digest %q", registry.Bound(), registry.Count(), registry.Digest())
} }
projection := string(registry.PromptInput().Content) if registry.IdentityDigest() == "" || registry.Digest() == registry.IdentityDigest() {
if !strings.Contains(projection, `"locations":[{"id":`) || !strings.Contains(projection, `"name":"The Tavern"`) || !strings.Contains(projection, `"name":"The Tavern"},{"id":`) { t.Fatalf("full/identity digests = %q/%q", registry.Digest(), registry.IdentityDigest())
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())
} }
} }
@@ -62,12 +52,10 @@ func TestRegistryLookupUsesIDAndReturnsDefensiveCopies(t *testing.T) {
locations[0].SourceRefs[0].SourceID = "changed" locations[0].SourceRefs[0].SourceID = "changed"
canonical := registry.CanonicalBytes() canonical := registry.CanonicalBytes()
canonical[0] = '[' 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" { 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) 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") t.Fatal("registry bytes mutated through accessor")
} }
} }

View File

@@ -63,8 +63,8 @@ func TestLocationRegistryHandoffProducesOccurrencesAndEvidence(t *testing.T) {
request := client.requestFor(t, locationoccurrences.PromptID) request := client.requestFor(t, locationoccurrences.PromptID)
registryInput := request.Inputs["location_registry"] 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) { 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 source-free generated ID projection", registryInput) t.Fatalf("occurrence registry input = %#v, want contextual selector projection", registryInput)
} }
contextArtifact := outputFileContent(t, output.OutputFiles, "evidence-context.json") contextArtifact := outputFileContent(t, output.OutputFiles, "evidence-context.json")
evidence, err := evidencecontext.New().Decode(contextArtifact) evidence, err := evidencecontext.New().Decode(contextArtifact)
@@ -218,7 +218,11 @@ func (client *locationHandoffLLMClient) CompleteStructured(ctx context.Context,
case locationoccurrences.PromptID: case locationoccurrences.PromptID:
var projection struct { var projection struct {
Locations []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"` } `json:"locations"`
} }
if err := json.Unmarshal(request.Inputs["location_registry"].Content, &projection); err != nil { 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 { if len(projection.Locations) != 2 {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has %d locations, want 2", len(projection.Locations)) 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: default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID) return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID)
} }