Improve scene-aware combat gating

This commit is contained in:
2026-07-25 21:08:24 +00:00
parent 3da20e9d6a
commit 2a7e025251
5 changed files with 107 additions and 14 deletions

View File

@@ -611,12 +611,13 @@ references:
lane: scene-descriptions lane: scene-descriptions
``` ```
When bound, the combat extractor and normalizer receive the generated registry When `npcs` is bound, the combat extractor and normalizer receive the generated
at operation time. Framework provenance and checkpoint dependencies contain its NPC registry at operation time. Framework provenance and checkpoint dependencies
kind, schema identity, media type, canonical digest, size, and bounded producer contain its kind, schema identity, media type, canonical digest, size, and
identity; names, content, and paths are not recorded there. When bounded producer identity; names, content, and paths are not recorded there.
absent, the combat prompt receives the exact empty registry value When absent, the combat prompt receives the exact empty NPC registry value
`{"npcs":[]}` with its projection digest and no registry provenance. `{"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 The complete example uses the same explicit scene binding; an external approved
scene-description artifact may be used instead when the workflow crosses a scene-description artifact may be used instead when the workflow crosses a
process or session boundary. process or session boundary.

View File

@@ -45,8 +45,8 @@ pipelines:
merge: appendorder merge: appendorder
normalize: dnd/scene-descriptions normalize: dnd/scene-descriptions
- id: extract-events - id: extract-events
# The accepted NPC artifact is supplied in memory to every compatible # Accepted NPC grounding and scene-description eligibility artifacts are
# extractor and normalizer in this step. # supplied in memory to their compatible consumers in this step.
references: references:
npcs: npcs:
artifact: artifact:

View File

@@ -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.", Message: "No exact scene classification was available; combat extraction was skipped.",
}}, }},
}, nil }, nil
default:
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("unsupported scene eligibility match state %q", match.State)
} }
order := shared.NewSourceRefOrder(req.Source) order := shared.NewSourceRefOrder(req.Source)
npcRegistry, err := e.npcResolver.Resolve(req.References) npcRegistry, err := e.npcResolver.Resolve(req.References)

View File

@@ -56,12 +56,13 @@ type Registry struct {
} }
// Resolver holds an immutable construction-time view and memoizes immutable // 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 { type Resolver struct {
seeded *Registry seeded *Registry
mu sync.Mutex mu sync.Mutex
cache map[string]*Registry cache map[string]*Registry
rawCache map[string]*Registry
} }
// NewResolver validates a materialized construction-time scene reference. An // NewResolver validates a materialized construction-time scene reference. An
@@ -72,7 +73,11 @@ func NewResolver(references contracts.ReferenceSet) (*Resolver, error) {
if err != nil { if err != nil {
return nil, err 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 { func constructionReferences(references contracts.ReferenceSet) contracts.ReferenceSet {
@@ -106,23 +111,59 @@ func (r *Resolver) Resolve(references contracts.ReferenceSet) (*Registry, error)
return r.seeded, nil 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) resolved, err := Resolve(references)
if err != nil { if err != nil {
return nil, err return nil, err
} }
r.mu.Lock()
defer r.mu.Unlock()
if sameEligibility(r.seeded, resolved) { if sameEligibility(r.seeded, resolved) {
if rawKey != "" {
r.rawCache[rawKey] = r.seeded
}
return r.seeded, nil return r.seeded, nil
} }
if cached, ok := r.cache[resolved.EligibilityDigest()]; ok { if cached, ok := r.cache[resolved.EligibilityDigest()]; ok {
if rawKey != "" {
r.rawCache[rawKey] = cached
}
return cached, nil return cached, nil
} }
r.cache[resolved.EligibilityDigest()] = resolved r.cache[resolved.EligibilityDigest()] = resolved
if rawKey != "" {
r.rawCache[rawKey] = resolved
}
return resolved, nil 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 { func sameEligibility(first, second *Registry) bool {
if first == nil || second == nil { if first == nil || second == nil {
return first == second return first == second

View File

@@ -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) { func TestRegistryIsImmutableAndResolverIsSafeForConcurrentReuse(t *testing.T) {
content := encodeList(t, sceneList(scene("chunk-1", "session-alpha", 1, 2, dnd.SceneKindCombat, "Title", "Summary."))) content := encodeList(t, sceneList(scene("chunk-1", "session-alpha", 1, 2, dnd.SceneKindCombat, "Title", "Summary.")))
references := referenceSet(referenceItem(content)) references := referenceSet(referenceItem(content))