Keep transcript sections fixed throughout processing
This commit is contained in:
@@ -525,6 +525,15 @@ required validation.
|
|||||||
|
|
||||||
## Stage 5 — Fix Chunk Boundaries for the Entire Run
|
## 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
|
**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
|
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
|
text of those same chunks, and apply fully approved candidates only within their
|
||||||
|
|||||||
@@ -131,8 +131,32 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
|
|||||||
|
|
||||||
working := cloneTranscript(input.Transcript)
|
working := cloneTranscript(input.Transcript)
|
||||||
results := make([]ModuleResult, 0, len(input.ModuleSpecs))
|
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()
|
startedAt := time.Now().UTC()
|
||||||
module, err := r.factory.ModuleForSpec(spec)
|
module, err := r.factory.ModuleForSpec(spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -145,31 +169,10 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
|
|||||||
CompletedAt: time.Now().UTC(),
|
CompletedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
results = append(results, failed)
|
results = append(results, failed)
|
||||||
return RunOutput{
|
return snapshot(), fmt.Errorf("module %q setup failed: %w", spec.InstanceName, err)
|
||||||
FinalTranscript: working,
|
|
||||||
ModuleResults: results,
|
|
||||||
}, fmt.Errorf("module %q setup failed: %w", spec.InstanceName, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
policy := module.ReplacementPolicy()
|
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{
|
pipelineResult, pipelineErr := runModulePipeline(ctx, collectSectionProposalsInput{
|
||||||
Module: module,
|
Module: module,
|
||||||
Spec: spec,
|
Spec: spec,
|
||||||
@@ -198,14 +201,19 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
|
|||||||
CompletedAt: time.Now().UTC(),
|
CompletedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
results = append(results, failed)
|
results = append(results, failed)
|
||||||
return RunOutput{
|
return snapshot(), fmt.Errorf("module %q failed: %w", spec.InstanceName, pipelineErr)
|
||||||
FinalTranscript: working,
|
|
||||||
ModuleResults: results,
|
|
||||||
}, fmt.Errorf("module %q failed: %w", spec.InstanceName, pipelineErr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyResult := proposals.ApplyProposals(working, pipelineResult.Approved, policy)
|
updated, applied, skipped, appliedBySection, applyErr := applyWithinSections(sections, pipelineResult.Approved, policy)
|
||||||
working = applyResult.Transcript
|
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{
|
results = append(results, ModuleResult{
|
||||||
ModuleKey: spec.ModuleKey,
|
ModuleKey: spec.ModuleKey,
|
||||||
@@ -216,17 +224,57 @@ func (r *Runner) run(ctx context.Context, input RunInput) (RunOutput, error) {
|
|||||||
Warnings: pipelineResult.Warnings,
|
Warnings: pipelineResult.Warnings,
|
||||||
ValidatorDecisions: pipelineResult.ValidatorDecisions,
|
ValidatorDecisions: pipelineResult.ValidatorDecisions,
|
||||||
ValidatorRejected: pipelineResult.ValidatorRejected,
|
ValidatorRejected: pipelineResult.ValidatorRejected,
|
||||||
AppliedChanges: applyResult.Applied,
|
AppliedChanges: applied,
|
||||||
SkippedChanges: applyResult.Skipped,
|
SkippedChanges: skipped,
|
||||||
StartedAt: startedAt,
|
StartedAt: startedAt,
|
||||||
CompletedAt: time.Now().UTC(),
|
CompletedAt: time.Now().UTC(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
output := RunOutput{FinalTranscript: working, ModuleResults: results}
|
output := snapshot()
|
||||||
return output, nil
|
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 {
|
type collectSectionProposalsInput struct {
|
||||||
Module contracts.TranscriptModule
|
Module contracts.TranscriptModule
|
||||||
Spec contracts.ModuleRunSpec
|
Spec contracts.ModuleRunSpec
|
||||||
|
|||||||
@@ -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) {
|
func TestRunnerProposalsExecutePerChunkSection(t *testing.T) {
|
||||||
cfg := runnerTestConfig()
|
cfg := runnerTestConfig()
|
||||||
cfg.Chunking.MaxSectionTokens = 3
|
cfg.Chunking.MaxSectionTokens = 3
|
||||||
|
|||||||
Reference in New Issue
Block a user