From b9b7384123122e69c8e7c5b28dc510ffb8a45b8a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 11 May 2026 13:32:17 +0000 Subject: [PATCH] Test chunking with proposal application --- .../chunk_proposal_composition_test.go | 304 ++++++++++++++++++ 1 file changed, 304 insertions(+) create mode 100644 internal/framework/contracts/chunk_proposal_composition_test.go diff --git a/internal/framework/contracts/chunk_proposal_composition_test.go b/internal/framework/contracts/chunk_proposal_composition_test.go new file mode 100644 index 0000000..48987e8 --- /dev/null +++ b/internal/framework/contracts/chunk_proposal_composition_test.go @@ -0,0 +1,304 @@ +package contracts + +import ( + "context" + "reflect" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/audita/internal/core/chunking" + "gitea.maximumdirect.net/eric/audita/internal/core/schema" + "gitea.maximumdirect.net/eric/audita/internal/framework/proposals" +) + +// testChunkProposalHarness is a test-only helper that composes existing +// deterministic chunking and proposal-application primitives. +// Production runner orchestration is implemented in later phases. +type testChunkProposalHarness struct { + module TranscriptModule +} + +func (h testChunkProposalHarness) collectEnrichedProposals( + ctx context.Context, + transcript *schema.Transcript, + sections []chunking.Section, +) ([]proposals.EnrichedCorrectionProposal, error) { + runSpecs, err := ResolveModuleRunSpecs([]string{h.module.Key()}) + if err != nil { + return nil, err + } + + runSpec := runSpecs[0] + runSpec.ReplacementPolicy = h.module.ReplacementPolicy() + + out := make([]proposals.EnrichedCorrectionProposal, 0) + nextIndex := 0 + + for _, section := range sections { + sectionMeta := SectionMetadataFromSection(section) + base, err := h.module.Propose(ctx, ProposalRequest{ + ExecutionContext: ExecutionContext{ + WorkingTranscript: transcript, + Section: §ionMeta, + }, + RunSpec: runSpec, + }) + if err != nil { + return nil, err + } + + for _, proposal := range base { + sectionIndex := section.Index + out = append(out, proposals.EnrichedCorrectionProposal{ + CorrectionProposal: proposal, + ProposalMetadata: proposals.ProposalMetadata{ + ProposalIndex: nextIndex, + ModuleKey: runSpec.ModuleKey, + ModuleInstance: runSpec.InstanceName, + SectionIndex: §ionIndex, + }, + }) + nextIndex++ + } + } + + return out, nil +} + +type deterministicReplacementRule struct { + OriginalText string + CorrectedText string + Confidence float64 +} + +type deterministicFakeModule struct { + key string + replacementPolicy proposals.ReplacementPolicy + rules []deterministicReplacementRule +} + +func (m deterministicFakeModule) Key() string { return m.key } + +func (m deterministicFakeModule) ReplacementPolicy() proposals.ReplacementPolicy { + return m.replacementPolicy +} + +func (m deterministicFakeModule) Validators() []Validator { return nil } + +func (m deterministicFakeModule) Propose(ctx context.Context, req ProposalRequest) ([]proposals.CorrectionProposal, error) { + _ = ctx + + if req.WorkingTranscript == nil || req.Section == nil { + return []proposals.CorrectionProposal{}, nil + } + + out := make([]proposals.CorrectionProposal, 0) + for _, seg := range req.WorkingTranscript.Segments { + if seg.ID < req.Section.StartSegmentID || seg.ID > req.Section.EndSegmentID { + continue + } + + for _, rule := range m.rules { + if strings.Contains(seg.Text, rule.OriginalText) { + out = append(out, proposals.CorrectionProposal{ + TargetSegmentID: seg.ID, + OriginalText: rule.OriginalText, + CorrectedText: rule.CorrectedText, + Confidence: rule.Confidence, + }) + } + } + } + + return out, nil +} + +func TestChunkProposalMetadataAssociation(t *testing.T) { + transcript := integrationTranscriptFixture() + sections := mustChunkFixtureTranscript(t, transcript) + + module := deterministicFakeModule{ + key: "fake_rewriter", + replacementPolicy: proposals.ReplacementPolicyRequireUnique, + rules: []deterministicReplacementRule{ + {OriginalText: "alpha", CorrectedText: "ALPHA", Confidence: 0.9}, + {OriginalText: "charlie", CorrectedText: "CHARLIE", Confidence: 0.9}, + }, + } + + harness := testChunkProposalHarness{module: module} + enriched, err := harness.collectEnrichedProposals(context.Background(), transcript, sections) + if err != nil { + t.Fatalf("collectEnrichedProposals failed: %v", err) + } + + if len(sections) != 2 { + t.Fatalf("expected 2 sections, got %d", len(sections)) + } + if len(enriched) != 2 { + t.Fatalf("expected 2 enriched proposals, got %d", len(enriched)) + } + + if enriched[0].SectionIndex == nil || *enriched[0].SectionIndex != 0 { + t.Fatalf("expected first proposal section_index=0, got %v", enriched[0].SectionIndex) + } + if enriched[1].SectionIndex == nil || *enriched[1].SectionIndex != 1 { + t.Fatalf("expected second proposal section_index=1, got %v", enriched[1].SectionIndex) + } +} + +func TestDeterministicProposalIndexAssignment(t *testing.T) { + transcript := integrationTranscriptFixture() + sections := mustChunkFixtureTranscript(t, transcript) + module := deterministicFakeModule{ + key: "fake_rewriter", + replacementPolicy: proposals.ReplacementPolicyRequireUnique, + rules: []deterministicReplacementRule{ + {OriginalText: "alpha", CorrectedText: "ALPHA", Confidence: 0.9}, + {OriginalText: "alpha one", CorrectedText: "ALPHA-ONE", Confidence: 0.9}, + {OriginalText: "charlie", CorrectedText: "CHARLIE", Confidence: 0.9}, + }, + } + + harness := testChunkProposalHarness{module: module} + first, err := harness.collectEnrichedProposals(context.Background(), transcript, sections) + if err != nil { + t.Fatalf("first collect failed: %v", err) + } + second, err := harness.collectEnrichedProposals(context.Background(), transcript, sections) + if err != nil { + t.Fatalf("second collect failed: %v", err) + } + + if len(first) != len(second) { + t.Fatalf("proposal length mismatch: %d vs %d", len(first), len(second)) + } + + for i := range first { + if first[i].ProposalIndex != i { + t.Fatalf("expected first pass proposal index %d at slot %d, got %d", i, i, first[i].ProposalIndex) + } + if second[i].ProposalIndex != i { + t.Fatalf("expected second pass proposal index %d at slot %d, got %d", i, i, second[i].ProposalIndex) + } + if first[i].TargetSegmentID != second[i].TargetSegmentID || + first[i].OriginalText != second[i].OriginalText || + first[i].CorrectedText != second[i].CorrectedText { + t.Fatalf("proposal mismatch at index %d", i) + } + } +} + +func TestApplyFakeProposalsAcrossMultipleChunksWithStaleSkip(t *testing.T) { + transcript := integrationTranscriptFixture() + sections := mustChunkFixtureTranscript(t, transcript) + + module := deterministicFakeModule{ + key: "fake_rewriter", + replacementPolicy: proposals.ReplacementPolicyRequireUnique, + rules: []deterministicReplacementRule{ + {OriginalText: "alpha", CorrectedText: "ALPHA", Confidence: 0.9}, + {OriginalText: "alpha one", CorrectedText: "ALPHA-ONE", Confidence: 0.9}, + {OriginalText: "charlie", CorrectedText: "CHARLIE", Confidence: 0.9}, + }, + } + + harness := testChunkProposalHarness{module: module} + enriched, err := harness.collectEnrichedProposals(context.Background(), transcript, sections) + if err != nil { + t.Fatalf("collectEnrichedProposals failed: %v", err) + } + + result := proposals.ApplyProposals(transcript, enriched, module.ReplacementPolicy()) + + if len(result.Applied) != 2 { + t.Fatalf("expected 2 applied changes, got %d", len(result.Applied)) + } + if len(result.Skipped) != 1 { + t.Fatalf("expected 1 skipped change, got %d", len(result.Skipped)) + } + if result.Skipped[0].SkipReason != proposals.SkipReasonMissingOriginalText { + t.Fatalf("expected stale skip reason %q, got %q", proposals.SkipReasonMissingOriginalText, result.Skipped[0].SkipReason) + } + + if got := result.Transcript.Segments[0].Text; got != "ALPHA one" { + t.Fatalf("unexpected segment 1 text: %q", got) + } + if got := result.Transcript.Segments[2].Text; got != "CHARLIE three" { + t.Fatalf("unexpected segment 3 text: %q", got) + } +} + +func TestChunkProposalCompositionDoesNotMutateNormalizedTranscript(t *testing.T) { + transcript := integrationTranscriptFixture() + before := cloneTranscriptForComparison(transcript) + sections := mustChunkFixtureTranscript(t, transcript) + + module := deterministicFakeModule{ + key: "fake_rewriter", + replacementPolicy: proposals.ReplacementPolicyRequireUnique, + rules: []deterministicReplacementRule{ + {OriginalText: "alpha", CorrectedText: "ALPHA", Confidence: 0.9}, + {OriginalText: "charlie", CorrectedText: "CHARLIE", Confidence: 0.9}, + }, + } + + harness := testChunkProposalHarness{module: module} + enriched, err := harness.collectEnrichedProposals(context.Background(), transcript, sections) + if err != nil { + t.Fatalf("collectEnrichedProposals failed: %v", err) + } + + _ = proposals.ApplyProposals(transcript, enriched, module.ReplacementPolicy()) + + if !reflect.DeepEqual(transcript, before) { + t.Fatalf("normalized transcript mutated\nbefore=%+v\nafter=%+v", before, transcript) + } +} + +func integrationTranscriptFixture() *schema.Transcript { + return &schema.Transcript{ + Segments: []schema.Segment{ + {ID: 1, Speaker: "A", Start: 0, End: 1, Text: "alpha one"}, + {ID: 2, Speaker: "A", Start: 1, End: 2, Text: "bravo two"}, + {ID: 3, Speaker: "B", Start: 2, End: 3, Text: "charlie three"}, + {ID: 4, Speaker: "B", Start: 3, End: 4, Text: "delta four"}, + }, + } +} + +func mustChunkFixtureTranscript(t *testing.T, transcript *schema.Transcript) []chunking.Section { + t.Helper() + chunker := chunking.NewChunkerWithEstimator( + chunking.ChunkingConfig{MaxSectionTokens: 2, MinSectionTokens: 0}, + &chunking.ConstTokenEstimator{Tokens: 1}, + ) + + sections, err := chunker.ChunkTranscript(transcript) + if err != nil { + t.Fatalf("chunk transcript failed: %v", err) + } + + if len(sections) != 2 { + t.Fatalf("expected fixture chunking to produce 2 sections, got %d", len(sections)) + } + + return sections +} + +func cloneTranscriptForComparison(in *schema.Transcript) *schema.Transcript { + if in == nil { + return nil + } + + out := &schema.Transcript{Segments: make([]schema.Segment, len(in.Segments))} + for i, seg := range in.Segments { + cloned := seg + if seg.Categories != nil { + cloned.Categories = append([]string(nil), seg.Categories...) + } + out.Segments[i] = cloned + } + + return out +}