Improve scene-aware combat gating
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user