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
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,

View File

@@ -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" }}

View File

@@ -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",

View File

@@ -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, &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")
}
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:

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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"
},

View File

@@ -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 {

View File

@@ -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")
}
}

View File

@@ -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)
}