Ground location occurrences with contextual selectors
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user