Retire annotated D&D scene plans

This commit is contained in:
2026-07-24 00:40:53 +00:00
parent cacf3f24e7
commit f5ed30e455
8 changed files with 39 additions and 51 deletions

View File

@@ -177,17 +177,16 @@ window settings used by `Plan`.
The scene chunker prepares a structured Scriptorium request from the full The scene chunker prepares a structured Scriptorium request from the full
transcript, session, and optional D&D reference inputs. It validates the model's transcript, session, and optional D&D reference inputs. It validates the model's
scene boundaries against source-unit IDs and converts them into deterministic inclusive source-unit endpoints against document position and converts them
plan ranges with optional scene annotations. Preparation injects the shared into deterministic plan ranges. Preparation injects the shared structured LLM
structured LLM client into the chunker; `Plan` client into the chunker; `Plan` supplies only the run-specific profile, session,
supplies only the run-specific profile, session, source, references, and source, references, and metadata.
metadata.
Scene validation requires sequential, contiguous, non-overlapping coverage from Scene validation requires sequential, contiguous, non-overlapping coverage from
the first source unit through the last. Scene descriptions, boundaries, the first source unit through the last. Its private response contains only the
confidence, and participants are module-owned annotations. Boundary caveats boundary endpoints; the accepted plan has no D&D-specific annotations and
become warnings. Malformed produces no boundary warnings. Malformed structured output is returned as an
structured output is returned as an error; there is no fallback chunker. error; there is no fallback chunker.
The package embeds its prompt and response schema and reports their non-secret The package embeds its prompt and response schema and reports their non-secret
identity and hashes through singleton module metadata. Shared D&D assets supply identity and hashes through singleton module metadata. Shared D&D assets supply

View File

@@ -16,12 +16,8 @@ not as committed release dates.
validator, and normalizer development. Treat model-quality review as an validator, and normalizer development. Treat model-quality review as an
iterative human evaluation aid, not a deterministic correctness gate. iterative human evaluation aid, not a deterministic correctness gate.
### Minimize And Use D&D Scene Chunking ### Use D&D Scene Chunking
- Reduce the D&D scene chunker toward its narrow responsibility: identifying
coherent scene boundaries. Retain boundary confidence or caveats only when a
demonstrated validator or operator workflow consumes them; move title,
summary, scene kind, and participant duties to dedicated artifacts.
- Allow the combat extractor to no-op for chunks classified as non-combat only - Allow the combat extractor to no-op for chunks classified as non-combat only
after the scene-description artifact can be supplied through an explicit after the scene-description artifact can be supplied through an explicit
ordered dependency. Do not make generic chunk materialization depend on a D&D ordered dependency. Do not make generic chunk materialization depend on a D&D

View File

@@ -1,6 +1,6 @@
# Minimal D&D Scene Chunking # Minimal D&D Scene Chunking
Status: Proposed Status: Implemented
## Purpose ## Purpose

View File

@@ -458,7 +458,7 @@ func TestProductionNormalizeValidatorOverrideRemainsAuthoritative(t *testing.T)
t.Fatalf("resolved validator chains = %#v, want normalize chain for %q", effective.ResolvedPipeline.ValidatorChains, spellnormalize.Key) t.Fatalf("resolved validator chains = %#v, want normalize chain for %q", effective.ResolvedPipeline.ValidatorChains, spellnormalize.Key)
} }
func TestProductionSceneRunRecordsChunkerWarningsAndProvenance(t *testing.T) { func TestProductionSceneRunRecordsAnnotationFreeChunkPlanAndProvenance(t *testing.T) {
outputRoot := filepath.Join(t.TempDir(), "output") outputRoot := filepath.Join(t.TempDir(), "output")
configPath := writeProductionContractConfig(t, productionRunConfig(outputRoot, "dnd/scenes")) configPath := writeProductionContractConfig(t, productionRunConfig(outputRoot, "dnd/scenes"))
fake := &productionFakeLLMClient{} fake := &productionFakeLLMClient{}
@@ -505,24 +505,17 @@ func TestProductionSceneRunRecordsChunkerWarningsAndProvenance(t *testing.T) {
if len(chunkMap.Chunks) != 1 || chunkMap.Chunks[0].ID != "chunk-000001" || chunkMap.Chunks[0].Index != 0 || chunkMap.Chunks[0].SourceRef.SourceID != "session-alpha" || chunkMap.Chunks[0].SourceRef.StartUnitID != 1 || chunkMap.Chunks[0].SourceRef.EndUnitID != 2 || chunkMap.Chunks[0].UnitCount != 2 { if len(chunkMap.Chunks) != 1 || chunkMap.Chunks[0].ID != "chunk-000001" || chunkMap.Chunks[0].Index != 0 || chunkMap.Chunks[0].SourceRef.SourceID != "session-alpha" || chunkMap.Chunks[0].SourceRef.StartUnitID != 1 || chunkMap.Chunks[0].SourceRef.EndUnitID != 2 || chunkMap.Chunks[0].UnitCount != 2 {
t.Fatalf("chunk map chunks = %#v, want one stable accepted scene range", chunkMap.Chunks) t.Fatalf("chunk map chunks = %#v, want one stable accepted scene range", chunkMap.Chunks)
} }
var planAnnotation struct { if len(chunkMap.PlanAnnotations) != 0 {
BoundaryCaveats []string `json:"boundary_caveats"` t.Fatalf("chunk map plan annotations = %#v, want none", chunkMap.PlanAnnotations)
} }
if err := json.Unmarshal(chunkMap.PlanAnnotations["dnd/scenes"], &planAnnotation); err != nil || len(planAnnotation.BoundaryCaveats) != 1 { if len(chunkMap.Chunks[0].Annotations) != 0 {
t.Fatalf("chunk map plan annotation = %s, %v; want scene caveat", chunkMap.PlanAnnotations["dnd/scenes"], err) t.Fatalf("chunk map range annotations = %#v, want none", chunkMap.Chunks[0].Annotations)
}
var rangeAnnotation struct {
ShortTitle string `json:"short_title"`
PrimaryMode string `json:"primary_mode"`
}
if err := json.Unmarshal(chunkMap.Chunks[0].Annotations["dnd/scenes"], &rangeAnnotation); err != nil || rangeAnnotation.ShortTitle != "Opening scene" || rangeAnnotation.PrimaryMode != "Narrative" {
t.Fatalf("chunk map range annotation = %s, %v; want surviving scene annotation", chunkMap.Chunks[0].Annotations["dnd/scenes"], err)
} }
warnings := readProductionJSON[struct { warnings := readProductionJSON[struct {
Warnings []contracts.Warning `json:"warnings"` Warnings []contracts.Warning `json:"warnings"`
}](t, filepath.Join(outputRoot, productionRunID, "warnings.json")) }](t, filepath.Join(outputRoot, productionRunID, "warnings.json"))
if len(warnings.Warnings) != 1 || warnings.Warnings[0].ReasonCode != "scene_boundary_caveat" { if len(warnings.Warnings) != 0 {
t.Fatalf("warnings = %#v, want one scene boundary warning", warnings.Warnings) t.Fatalf("warnings = %#v, want none", warnings.Warnings)
} }
if len(fake.requestsFor(scenes.PromptID)) != 1 || len(fake.requestsFor(spells.PromptID)) != 1 { if len(fake.requestsFor(scenes.PromptID)) != 1 || len(fake.requestsFor(spells.PromptID)) != 1 {
t.Fatalf("fake prompt requests = %#v, want one scene and one spell request", fake.requestPrompts()) t.Fatalf("fake prompt requests = %#v, want one scene and one spell request", fake.requestPrompts())
@@ -725,7 +718,7 @@ func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, r
var content []byte var content []byte
switch req.PromptID { switch req.PromptID {
case scenes.PromptID: case scenes.PromptID:
content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":2,"short_title":"Opening scene","primary_mode":"Narrative","main_participants":["Aria"],"summary":"The session opens.","boundary_note":"The opening covers the available transcript.","boundary_confidence":"High"}],"boundary_caveats":["The opening boundary is inferred from the short transcript."]}`) content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":2}]}`)
case spells.PromptID: case spells.PromptID:
if client.spellResponse != "" { if client.spellResponse != "" {
content = []byte(client.spellResponse) content = []byte(client.spellResponse)

View File

@@ -61,7 +61,7 @@ func TestCodecRoundTripsValidFixture(t *testing.T) {
func TestBuildCanonicalizesAnnotationFormatting(t *testing.T) { func TestBuildCanonicalizesAnnotationFormatting(t *testing.T) {
first := acceptedBuildRequest(t) first := acceptedBuildRequest(t)
second := acceptedBuildRequest(t) second := acceptedBuildRequest(t)
second.Plan.Annotations["dnd/scenes"] = json.RawMessage(" { \n \t\"title\" : \"Gate\" \n } ") second.Plan.Annotations["test/chunker"] = json.RawMessage(" { \n \t\"label\" : \"fixture\" \n } ")
canonical, err := source.CanonicalizeChunkPlan(second.Plan) canonical, err := source.CanonicalizeChunkPlan(second.Plan)
if err != nil { if err != nil {
t.Fatalf("CanonicalizeChunkPlan() error = %v", err) t.Fatalf("CanonicalizeChunkPlan() error = %v", err)
@@ -108,7 +108,7 @@ func TestCodecRejectsInvalidDurableBoundaries(t *testing.T) {
{name: "invalid range", mutate: func(value *ChunkMap) { value.Chunks[0].SourceRef.StartUnitID = 0 }}, {name: "invalid range", mutate: func(value *ChunkMap) { value.Chunks[0].SourceRef.StartUnitID = 0 }},
{name: "invalid count", mutate: func(value *ChunkMap) { value.Chunks[0].UnitCount = 0 }}, {name: "invalid count", mutate: func(value *ChunkMap) { value.Chunks[0].UnitCount = 0 }},
{name: "invalid namespace", mutate: func(value *ChunkMap) { value.PlanAnnotations[" "] = json.RawMessage(`null`) }}, {name: "invalid namespace", mutate: func(value *ChunkMap) { value.PlanAnnotations[" "] = json.RawMessage(`null`) }},
{name: "invalid annotation", mutate: func(value *ChunkMap) { value.Chunks[0].Annotations["dnd/scenes"] = json.RawMessage(`{`) }}, {name: "invalid annotation", mutate: func(value *ChunkMap) { value.Chunks[0].Annotations["test/chunker"] = json.RawMessage(`{`) }},
{name: "plan digest mismatch", mutate: func(value *ChunkMap) { value.PlanDigest = "sha256:" + strings.Repeat("a", 64) }}, {name: "plan digest mismatch", mutate: func(value *ChunkMap) { value.PlanDigest = "sha256:" + strings.Repeat("a", 64) }},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@@ -131,13 +131,13 @@ func TestCodecRejectsInvalidDurableBoundaries(t *testing.T) {
t.Fatalf("Decode(%s) error = nil, want strict JSON rejection", raw) t.Fatalf("Decode(%s) error = nil, want strict JSON rejection", raw)
} }
} }
formatted := bytes.Replace(content, []byte(`{"title":"Gate"}`), []byte("{\n \"title\": \"Gate\"\n}"), 1) formatted := bytes.Replace(content, []byte(`{"label":"fixture"}`), []byte("{\n \"label\": \"fixture\"\n}"), 1)
decoded, err := New().Decode(formatted) decoded, err := New().Decode(formatted)
if err != nil { if err != nil {
t.Fatalf("Decode(formatted annotations) error = %v", err) t.Fatalf("Decode(formatted annotations) error = %v", err)
} }
if string(decoded.PlanAnnotations["dnd/scenes"]) != `{"title":"Gate"}` { if string(decoded.PlanAnnotations["test/chunker"]) != `{"label":"fixture"}` {
t.Fatalf("decoded annotation = %s, want canonical JSON", decoded.PlanAnnotations["dnd/scenes"]) t.Fatalf("decoded annotation = %s, want canonical JSON", decoded.PlanAnnotations["test/chunker"])
} }
} }
@@ -187,7 +187,7 @@ func TestEncodeDoesNotMutateValue(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
value.Chunks[0].Annotations["dnd/scenes"] = json.RawMessage(" { \n \"kind\" : \"narrative\" \n } ") value.Chunks[0].Annotations["test/chunker"] = json.RawMessage(" { \n \"category\" : \"sample\" \n } ")
before := clone(value) before := clone(value)
if _, err := New().Encode(value); err != nil { if _, err := New().Encode(value); err != nil {
t.Fatalf("Encode() error = %v", err) t.Fatalf("Encode() error = %v", err)
@@ -203,14 +203,14 @@ func TestChunkMapOwnershipIsIndependent(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
request.Plan.Annotations["dnd/scenes"][0] = '[' request.Plan.Annotations["test/chunker"][0] = '['
first.PlanAnnotations["dnd/scenes"][0] = '[' first.PlanAnnotations["test/chunker"][0] = '['
second, err := Build(acceptedBuildRequest(t)) second, err := Build(acceptedBuildRequest(t))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if string(second.PlanAnnotations["dnd/scenes"]) != `{"title":"Gate"}` { if string(second.PlanAnnotations["test/chunker"]) != `{"label":"fixture"}` {
t.Fatalf("Build() shared mutable annotations: %s", second.PlanAnnotations["dnd/scenes"]) t.Fatalf("Build() shared mutable annotations: %s", second.PlanAnnotations["test/chunker"])
} }
} }
@@ -232,11 +232,11 @@ func chunkDocument(value map[string]any, index int) map[string]any {
func acceptedBuildRequest(t *testing.T) BuildRequest { func acceptedBuildRequest(t *testing.T) BuildRequest {
t.Helper() t.Helper()
document := &source.SourceDocument{ document := &source.SourceDocument{
ID: "session-7", Kind: "transcript", Format: "application/json", ID: "source-test", Kind: "transcript", Format: "application/json",
Units: []source.SourceUnit{ Units: []source.SourceUnit{
{ID: 10, Kind: "segment", Text: "At the gate.", Ref: source.SourceRef{SourceID: "session-7", StartUnitID: 10, EndUnitID: 10}}, {ID: 10, Kind: "segment", Text: "First unit.", Ref: source.SourceRef{SourceID: "source-test", StartUnitID: 10, EndUnitID: 10}},
{ID: 3, Kind: "segment", Text: "The guard speaks.", Ref: source.SourceRef{SourceID: "session-7", StartUnitID: 3, EndUnitID: 3}}, {ID: 3, Kind: "segment", Text: "Second unit.", Ref: source.SourceRef{SourceID: "source-test", StartUnitID: 3, EndUnitID: 3}},
{ID: 20, Kind: "segment", Text: "The party enters.", Ref: source.SourceRef{SourceID: "session-7", StartUnitID: 20, EndUnitID: 20}}, {ID: 20, Kind: "segment", Text: "Third unit.", Ref: source.SourceRef{SourceID: "source-test", StartUnitID: 20, EndUnitID: 20}},
}, },
} }
digest, err := source.DigestDocument(document) digest, err := source.DigestDocument(document)
@@ -246,9 +246,9 @@ func acceptedBuildRequest(t *testing.T) BuildRequest {
document.Digest = digest document.Digest = digest
plan := source.ChunkPlan{ plan := source.ChunkPlan{
SourceDigest: digest, SourceDigest: digest,
Annotations: source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"title":"Gate"}`)}, Annotations: source.ChunkAnnotations{"test/chunker": json.RawMessage(`{"label":"fixture"}`)},
Ranges: []source.ChunkRange{ Ranges: []source.ChunkRange{
{StartUnitID: 10, EndUnitID: 3, Annotations: source.ChunkAnnotations{"dnd/scenes": json.RawMessage(`{"kind":"narrative"}`)}}, {StartUnitID: 10, EndUnitID: 3, Annotations: source.ChunkAnnotations{"test/chunker": json.RawMessage(`{"category":"sample"}`)}},
{StartUnitID: 20, EndUnitID: 20}, {StartUnitID: 20, EndUnitID: 20},
}, },
} }
@@ -257,7 +257,7 @@ func acceptedBuildRequest(t *testing.T) BuildRequest {
t.Fatal(err) t.Fatal(err)
} }
return BuildRequest{ return BuildRequest{
Source: document, Plan: plan, Chunks: chunks, RequestedChunker: "dnd/scenes", Source: document, Plan: plan, Chunks: chunks, RequestedChunker: "chunk/requested",
Producer: Producer{InputModule: "seriatim", ChunkModule: "dnd/scenes", LLMProfile: "dnd-scenes"}, Producer: Producer{InputModule: "input/producer", ChunkModule: "chunk/producer", LLMProfile: "profile/test"},
} }
} }

View File

@@ -1 +1 @@
{"source_id":"session-7","source_digest":"sha256:87d04d40537217e2adcbdec841c5873dbc1034c426d83c4578a0431a5ef855f6","plan_digest":"sha256:a6bfc33d52f1c0f4eb3287dd4a00e8672b3d3d9579f9c0366f18a5ff0dba7d14","requested_chunker":"dnd/scenes","producer":{"input_module":"seriatim","chunk_module":"dnd/scenes","llm_profile":"dnd-scenes"},"plan_annotations":{"dnd/scenes":{"title":"Gate"}},"chunks":[{"id":"chunk-000001","index":0,"source_ref":{"source_id":"session-7","start_unit_id":10,"end_unit_id":3},"unit_count":2,"annotations":{"dnd/scenes":{"kind":"narrative"}}},{"id":"chunk-000002","index":1,"source_ref":{"source_id":"session-7","start_unit_id":20,"end_unit_id":20},"unit_count":1,"annotations":{}}]} {"source_id":"source-test","source_digest":"sha256:186b2d30029e7fda40545f88e75ade38f22bff3e5543af4dfeb86587536a01af","plan_digest":"sha256:e50cc7da9070ac8a4339d79b2c206be842846c5c22af7b0ad58f7e9a34ceaf97","requested_chunker":"chunk/requested","producer":{"input_module":"input/producer","chunk_module":"chunk/producer","llm_profile":"profile/test"},"plan_annotations":{"test/chunker":{"label":"fixture"}},"chunks":[{"id":"chunk-000001","index":0,"source_ref":{"source_id":"source-test","start_unit_id":10,"end_unit_id":3},"unit_count":2,"annotations":{"test/chunker":{"category":"sample"}}},{"id":"chunk-000002","index":1,"source_ref":{"source_id":"source-test","start_unit_id":20,"end_unit_id":20},"unit_count":1,"annotations":{}}]}

View File

@@ -281,7 +281,7 @@ func TestFilesystemStoreReportsInvalidRecordsAsRecoverable(t *testing.T) {
return bytes.Replace(data, []byte(`{"schema_version"`), []byte(`{"SENTINEL_UNKNOWN_FIELD":true,"schema_version"`), 1) return bytes.Replace(data, []byte(`{"schema_version"`), []byte(`{"SENTINEL_UNKNOWN_FIELD":true,"schema_version"`), 1)
}}, }},
{name: "truncated JSON", mutate: func(data []byte) []byte { return data[:len(data)/2] }}, {name: "truncated JSON", mutate: func(data []byte) []byte { return data[:len(data)/2] }},
{name: "schema mismatch", mutate: replaceJSON(`notarius.chunk-plan.v1`, `SENTINEL_SCHEMA_VALUE`)}, {name: "legacy v1 record", mutate: replaceJSON(`notarius.chunk-plan.v2`, `notarius.chunk-plan.v1`)},
{name: "source mismatch", mutate: replaceJSON(testSourceDigest, "sha256:"+strings.Repeat("b", 64))}, {name: "source mismatch", mutate: replaceJSON(testSourceDigest, "sha256:"+strings.Repeat("b", 64))},
{name: "plan digest mismatch", mutate: func(data []byte) []byte { {name: "plan digest mismatch", mutate: func(data []byte) []byte {
prefix := []byte(`"plan_digest":"sha256:`) prefix := []byte(`"plan_digest":"sha256:`)

View File

@@ -8,7 +8,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
) )
const ChunkPlanSchemaVersion = "notarius.chunk-plan.v1" const ChunkPlanSchemaVersion = "notarius.chunk-plan.v2"
type ChunkPlanProducer struct { type ChunkPlanProducer struct {
InputModule string `json:"input_module"` InputModule string `json:"input_module"`