diff --git a/docs/config.md b/docs/config.md index 9a0b498..d402f11 100644 --- a/docs/config.md +++ b/docs/config.md @@ -611,12 +611,13 @@ references: lane: scene-descriptions ``` -When bound, the combat extractor and normalizer receive the generated registry -at operation time. Framework provenance and checkpoint dependencies contain its -kind, schema identity, media type, canonical digest, size, and bounded producer -identity; names, content, and paths are not recorded there. When -absent, the combat prompt receives the exact empty registry value -`{"npcs":[]}` with its projection digest and no registry provenance. +When `npcs` is bound, the combat extractor and normalizer receive the generated +NPC registry at operation time. Framework provenance and checkpoint dependencies +contain its kind, schema identity, media type, canonical digest, size, and +bounded producer identity; names, content, and paths are not recorded there. +When absent, the combat prompt receives the exact empty NPC registry value +`{"npcs":[]}` with its projection digest and no registry provenance. The +generated `scene_descriptions` artifact is supplied only to combat extraction. The complete example uses the same explicit scene binding; an external approved scene-description artifact may be used instead when the workflow crosses a process or session boundary. diff --git a/examples/dnd-complete.config.yml b/examples/dnd-complete.config.yml index 9786506..586a869 100644 --- a/examples/dnd-complete.config.yml +++ b/examples/dnd-complete.config.yml @@ -45,8 +45,8 @@ pipelines: merge: appendorder normalize: dnd/scene-descriptions - id: extract-events - # The accepted NPC artifact is supplied in memory to every compatible - # extractor and normalizer in this step. + # Accepted NPC grounding and scene-description eligibility artifacts are + # supplied in memory to their compatible consumers in this step. references: npcs: artifact: diff --git a/internal/modules/dnd/extract/combatturns/extractor.go b/internal/modules/dnd/extract/combatturns/extractor.go index 7c907aa..af6091a 100644 --- a/internal/modules/dnd/extract/combatturns/extractor.go +++ b/internal/modules/dnd/extract/combatturns/extractor.go @@ -196,6 +196,8 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe Message: "No exact scene classification was available; combat extraction was skipped.", }}, }, nil + default: + return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("unsupported scene eligibility match state %q", match.State) } order := shared.NewSourceRefOrder(req.Source) npcRegistry, err := e.npcResolver.Resolve(req.References) diff --git a/internal/modules/dnd/scenedescriptions/registry/registry.go b/internal/modules/dnd/scenedescriptions/registry/registry.go index ffe0a6e..bef6a76 100644 --- a/internal/modules/dnd/scenedescriptions/registry/registry.go +++ b/internal/modules/dnd/scenedescriptions/registry/registry.go @@ -56,12 +56,13 @@ type Registry struct { } // Resolver holds an immutable construction-time view and memoizes immutable -// operation-time views by eligibility digest. +// operation-time views by raw reference identity and eligibility digest. type Resolver struct { seeded *Registry - mu sync.Mutex - cache map[string]*Registry + mu sync.Mutex + cache map[string]*Registry + rawCache map[string]*Registry } // NewResolver validates a materialized construction-time scene reference. An @@ -72,7 +73,11 @@ func NewResolver(references contracts.ReferenceSet) (*Resolver, error) { if err != nil { return nil, err } - return &Resolver{seeded: seeded, cache: make(map[string]*Registry)}, nil + return &Resolver{ + seeded: seeded, + cache: make(map[string]*Registry), + rawCache: make(map[string]*Registry), + }, nil } func constructionReferences(references contracts.ReferenceSet) contracts.ReferenceSet { @@ -106,23 +111,59 @@ func (r *Resolver) Resolve(references contracts.ReferenceSet) (*Registry, error) return r.seeded, nil } + slot := references.Slots[ReferenceSlot] + rawKey := "" + if len(slot.Items) == 1 { + rawKey = rawReferenceKey(slot.Items[0]) + } + + r.mu.Lock() + defer r.mu.Unlock() + if rawKey != "" { + if cached, ok := r.rawCache[rawKey]; ok { + return cached, nil + } + } resolved, err := Resolve(references) if err != nil { return nil, err } - r.mu.Lock() - defer r.mu.Unlock() if sameEligibility(r.seeded, resolved) { + if rawKey != "" { + r.rawCache[rawKey] = r.seeded + } return r.seeded, nil } if cached, ok := r.cache[resolved.EligibilityDigest()]; ok { + if rawKey != "" { + r.rawCache[rawKey] = cached + } return cached, nil } r.cache[resolved.EligibilityDigest()] = resolved + if rawKey != "" { + r.rawCache[rawKey] = resolved + } return resolved, nil } +func rawReferenceKey(item contracts.ReferenceItem) string { + digest := strings.ToLower(strings.TrimSpace(item.Digest)) + if !validSHA256Digest(digest) { + digest = semanticDigest(item.Content) + } + return strings.ToLower(strings.TrimSpace(item.MediaType)) + "\x00" + digest +} + +func validSHA256Digest(value string) bool { + if len(value) != len("sha256:")+sha256.Size*2 || !strings.HasPrefix(value, "sha256:") { + return false + } + decoded, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:")) + return err == nil && len(decoded) == sha256.Size +} + func sameEligibility(first, second *Registry) bool { if first == nil || second == nil { return first == second diff --git a/internal/modules/dnd/scenedescriptions/registry/registry_test.go b/internal/modules/dnd/scenedescriptions/registry/registry_test.go index 9b0db8c..2ad4a76 100644 --- a/internal/modules/dnd/scenedescriptions/registry/registry_test.go +++ b/internal/modules/dnd/scenedescriptions/registry/registry_test.go @@ -129,6 +129,55 @@ func TestResolverUsesGeneratedReferenceWhenPresent(t *testing.T) { } } +func TestResolverReusesRawAndSemanticallyEquivalentViews(t *testing.T) { + baseContent := encodeList(t, sceneList( + scene("chunk-1", "session-alpha", 1, 2, dnd.SceneKindCombat, "Original title", "Original summary."), + )) + baseItem := referenceItem(baseContent) + baseItem.Digest = semanticDigest(baseContent) + resolver, err := NewResolver(contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {}}}) + if err != nil { + t.Fatal(err) + } + + first, err := resolver.Resolve(referenceSet(baseItem)) + if err != nil { + t.Fatal(err) + } + second, err := resolver.Resolve(referenceSet(baseItem)) + if err != nil { + t.Fatal(err) + } + if second != first { + t.Fatal("identical framework reference did not reuse its immutable view") + } + + equivalentContent := encodeList(t, sceneList( + scene("chunk-1", "session-alpha", 1, 2, dnd.SceneKindCombat, "Revised title", "Revised summary."), + )) + equivalentItem := referenceItem(equivalentContent) + equivalentItem.Digest = semanticDigest(equivalentContent) + equivalent, err := resolver.Resolve(referenceSet(equivalentItem)) + if err != nil { + t.Fatal(err) + } + if equivalent != first { + t.Fatal("prose-only change did not reuse the semantic eligibility view") + } + + for _, digest := range []string{"", "not-a-sha256-digest"} { + fallbackItem := referenceItem(baseContent) + fallbackItem.Digest = digest + fallback, resolveErr := resolver.Resolve(referenceSet(fallbackItem)) + if resolveErr != nil { + t.Fatal(resolveErr) + } + if fallback != first { + t.Fatalf("digest fallback %q did not reuse the content-addressed view", digest) + } + } +} + func TestRegistryIsImmutableAndResolverIsSafeForConcurrentReuse(t *testing.T) { content := encodeList(t, sceneList(scene("chunk-1", "session-alpha", 1, 2, dnd.SceneKindCombat, "Title", "Summary."))) references := referenceSet(referenceItem(content))