Keep transcript sections fixed throughout processing

This commit is contained in:
2026-09-24 17:17:14 +00:00
parent 0a19f163c6
commit bb21a81805
3 changed files with 157 additions and 32 deletions

View File

@@ -525,6 +525,15 @@ required validation.
## Stage 5 — Fix Chunk Boundaries for the Entire Run
**Completed 2026-09-24.** The runner now partitions once before the module loop,
returns the initial section inventory, applies approved corrections only inside
their identified sections, carries current text forward, and recombines the
transcript after processing. Tests cover substantial text growth, stable segment
membership, empty work, and impossible chunk constraints. `go test
./internal/core/chunking` and `git diff --check` passed. Runner tests remain
unavailable because warning-field consumers still fail compilation; Stage 6
replaces them with operation outcomes.
**Work:** Move the runner's chunking call before its stage loop. Own fixed chunk
states and initial inventory in runner output. Execute each module using the current
text of those same chunks, and apply fully approved candidates only within their

View File

@@ -131,8 +131,32 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
working := cloneTranscript(input.Transcript)
results := make([]ModuleResult, 0, len(input.ModuleSpecs))
sections, err := chunkWorkingTranscript(input.Config, working)
if err != nil {
return RunOutput{FinalTranscript: working}, fmt.Errorf("chunk normalized transcript: %w", err)
}
inventory := make([]contracts.SectionMetadata, len(sections))
workUnits := make([]WorkUnitOutcome, 0, len(input.ModuleSpecs)*len(sections))
for i, section := range sections {
inventory[i] = contracts.SectionMetadataFromSection(section)
}
for moduleIndex, spec := range input.ModuleSpecs {
for _, section := range inventory {
workUnits = append(workUnits, WorkUnitOutcome{
ModuleIndex: moduleIndex, ModuleKey: spec.ModuleKey,
ModuleInstance: spec.InstanceName, Section: section,
State: outcomes.WorkNotStarted,
})
}
}
snapshot := func() RunOutput {
return RunOutput{
FinalTranscript: transcriptFromSections(sections), ModuleResults: results,
Sections: inventory, WorkUnits: workUnits,
}
}
for _, spec := range input.ModuleSpecs {
for moduleIndex, spec := range input.ModuleSpecs {
startedAt := time.Now().UTC()
module, err := r.factory.ModuleForSpec(spec)
if err != nil {
@@ -145,31 +169,10 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
CompletedAt: time.Now().UTC(),
}
results = append(results, failed)
return RunOutput{
FinalTranscript: working,
ModuleResults: results,
}, fmt.Errorf("module %q setup failed: %w", spec.InstanceName, err)
return snapshot(), fmt.Errorf("module %q setup failed: %w", spec.InstanceName, err)
}
policy := module.ReplacementPolicy()
sections, err := chunkWorkingTranscript(input.Config, working)
if err != nil {
failed := ModuleResult{
ModuleKey: spec.ModuleKey,
ModuleInstance: spec.InstanceName,
ReplacementPolicy: policy,
Status: ModuleStatusFailed,
ErrorMessage: err.Error(),
StartedAt: startedAt,
CompletedAt: time.Now().UTC(),
}
results = append(results, failed)
return RunOutput{
FinalTranscript: working,
ModuleResults: results,
}, fmt.Errorf("module %q chunking failed: %w", spec.InstanceName, err)
}
pipelineResult, pipelineErr := runModulePipeline(ctx, collectSectionProposalsInput{
Module: module,
Spec: spec,
@@ -198,14 +201,19 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
CompletedAt: time.Now().UTC(),
}
results = append(results, failed)
return RunOutput{
FinalTranscript: working,
ModuleResults: results,
}, fmt.Errorf("module %q failed: %w", spec.InstanceName, pipelineErr)
return snapshot(), fmt.Errorf("module %q failed: %w", spec.InstanceName, pipelineErr)
}
applyResult := proposals.ApplyProposals(working, pipelineResult.Approved, policy)
working = applyResult.Transcript
updated, applied, skipped, appliedBySection, applyErr := applyWithinSections(sections, pipelineResult.Approved, policy)
if applyErr != nil {
return snapshot(), fmt.Errorf("module %q application failed: %w", spec.InstanceName, applyErr)
}
sections = updated
for sectionPos := range sections {
unit := &workUnits[moduleIndex*len(sections)+sectionPos]
unit.State = outcomes.WorkCompleted
unit.CorrectionsApplied = appliedBySection[sectionPos]
}
results = append(results, ModuleResult{
ModuleKey: spec.ModuleKey,
@@ -216,17 +224,57 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
Warnings: pipelineResult.Warnings,
ValidatorDecisions: pipelineResult.ValidatorDecisions,
ValidatorRejected: pipelineResult.ValidatorRejected,
AppliedChanges: applyResult.Applied,
SkippedChanges: applyResult.Skipped,
AppliedChanges: applied,
SkippedChanges: skipped,
StartedAt: startedAt,
CompletedAt: time.Now().UTC(),
})
}
output := RunOutput{FinalTranscript: working, ModuleResults: results}
output := snapshot()
return output, nil
}
func transcriptFromSections(sections []chunking.Section) *schema.Transcript {
segments := make([]schema.Segment, 0)
for _, section := range sections {
segments = append(segments, section.Segments...)
}
return &schema.Transcript{Segments: segments}
}
// applyWithinSections leaves section membership and initial metadata unchanged.
// Approved proposals are applied only to their identified section.
func applyWithinSections(sections []chunking.Section, approved []proposals.EnrichedCorrectionProposal, policy proposals.ReplacementPolicy) ([]chunking.Section, []proposals.AppliedChange, []proposals.SkippedChange, []int, error) {
bySection := make(map[int][]proposals.EnrichedCorrectionProposal, len(sections))
known := make(map[int]struct{}, len(sections))
for _, section := range sections {
known[section.Index] = struct{}{}
}
for _, candidate := range approved {
if candidate.SectionIndex == nil {
return nil, nil, nil, nil, fmt.Errorf("approved proposal %d has no section identity", candidate.ProposalIndex)
}
if _, ok := known[*candidate.SectionIndex]; !ok {
return nil, nil, nil, nil, fmt.Errorf("approved proposal %d has unknown section identity", candidate.ProposalIndex)
}
bySection[*candidate.SectionIndex] = append(bySection[*candidate.SectionIndex], candidate)
}
updated := make([]chunking.Section, len(sections))
copy(updated, sections)
applied := make([]proposals.AppliedChange, 0)
skipped := make([]proposals.SkippedChange, 0)
appliedBySection := make([]int, len(sections))
for i, section := range sections {
result := proposals.ApplyProposals(transcriptFromSection(section), bySection[section.Index], policy)
updated[i].Segments = result.Transcript.Segments
applied = append(applied, result.Applied...)
skipped = append(skipped, result.Skipped...)
appliedBySection[i] = len(result.Applied)
}
return updated, applied, skipped, appliedBySection, nil
}
type collectSectionProposalsInput struct {
Module contracts.TranscriptModule
Spec contracts.ModuleRunSpec

View File

@@ -162,6 +162,74 @@ func TestRunnerModulesRunSequentially(t *testing.T) {
}
}
func TestRunnerKeepsSectionMembershipAfterTextGrowth(t *testing.T) {
cfg := runnerTestConfig()
cfg.Chunking.MaxSectionTokens = 3
cfg.Chunking.MinSectionTokens = 0
transcript := &schema.Transcript{Segments: []schema.Segment{
{ID: 1, Text: "a"}, {ID: 2, Text: "b"}, {ID: 3, Text: "c"}, {ID: 4, Text: "d"},
}}
var mu sync.Mutex
seen := make(map[int][]int)
updatedText := "a one two three four five"
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{
"grow": fakeModule{key: "grow", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
if req.Section.Index != 0 {
return nil, nil
}
return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "a", CorrectedText: updatedText, Confidence: 1}}, nil
}},
"inspect": fakeModule{key: "inspect", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) {
ids := make([]int, len(req.WorkingTranscript.Segments))
for i, segment := range req.WorkingTranscript.Segments {
ids[i] = segment.ID
}
mu.Lock()
seen[req.Section.Index] = ids
mu.Unlock()
if req.Section.Index == 0 && req.WorkingTranscript.Segments[0].Text != updatedText {
return nil, errors.New("later module did not see updated section text")
}
return nil, nil
}},
}})
out, err := r.Run(context.Background(), RunInput{Config: &cfg, Transcript: transcript, ModuleSpecs: []contracts.ModuleRunSpec{{ModuleKey: "grow", InstanceName: "grow"}, {ModuleKey: "inspect", InstanceName: "inspect"}}})
if err != nil {
t.Fatal(err)
}
if len(out.Sections) != 2 || out.Sections[0].StartSegmentID != 1 || out.Sections[0].EndSegmentID != 2 || out.Sections[0].EstimatedTokens != 2 || len(out.WorkUnits) != 4 {
t.Fatalf("initial inventory changed: sections=%+v units=%+v", out.Sections, out.WorkUnits)
}
if len(seen) != 2 || len(seen[0]) != 2 || seen[0][0] != 1 || seen[0][1] != 2 || len(seen[1]) != 2 || seen[1][0] != 3 || seen[1][1] != 4 {
t.Fatalf("later module saw changed section membership: %+v", seen)
}
if len(out.FinalTranscript.Segments) != 4 || out.FinalTranscript.Segments[0].Text != updatedText || out.FinalTranscript.Segments[3].ID != 4 {
t.Fatalf("recombined transcript: %+v", out.FinalTranscript)
}
if out.WorkUnits[0].CorrectionsApplied != 1 || out.WorkUnits[1].CorrectionsApplied != 0 {
t.Fatalf("section application counts: %+v", out.WorkUnits)
}
}
func TestRunnerInventoriesEmptyWorkAndRejectsImpossibleChunking(t *testing.T) {
r := New(fakeFactory{modules: map[string]contracts.TranscriptModule{}})
empty, err := r.Run(context.Background(), RunInput{Transcript: &schema.Transcript{}})
if err != nil || len(empty.Sections) != 0 || len(empty.WorkUnits) != 0 || len(empty.FinalTranscript.Segments) != 0 {
t.Fatalf("empty transcript and pipeline: %+v, %v", empty, err)
}
input := &schema.Transcript{Segments: []schema.Segment{{ID: 1, Text: "one"}, {ID: 2, Text: "two"}}}
noModules, err := r.Run(context.Background(), RunInput{Transcript: input})
if err != nil || len(noModules.Sections) != 1 || len(noModules.WorkUnits) != 0 || len(noModules.FinalTranscript.Segments) != 2 {
t.Fatalf("empty pipeline inventory: %+v, %v", noModules, err)
}
cfg := runnerTestConfig()
target := 3
cfg.Chunking.TargetSections = &target
if _, err := r.Run(context.Background(), RunInput{Config: &cfg, Transcript: input}); err == nil {
t.Fatal("impossible section target was accepted")
}
}
func TestRunnerProposalsExecutePerChunkSection(t *testing.T) {
cfg := runnerTestConfig()
cfg.Chunking.MaxSectionTokens = 3