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
```
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.

View File

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

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.",
}},
}, 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)

View File

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

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