diff --git a/internal/reasoningmode/corpus_feed_replay_test.go b/internal/reasoningmode/corpus_feed_replay_test.go new file mode 100644 index 0000000..dc52ef0 --- /dev/null +++ b/internal/reasoningmode/corpus_feed_replay_test.go @@ -0,0 +1,108 @@ +package reasoningmode + +import ( + "bufio" + "encoding/json" + "os" + "testing" +) + +// TestReplayProductionCorpusThroughFeed replays production reasoning answers +// through the streaming feed the card pump actually uses, and checks two +// properties of every frame the card receives: +// +// 1. a frame that already contains a structural body line must start at that +// body (no narration glued in front of it). A frame that shows narration +// above the body is exactly the "the last reasoning sentence stays on the +// card" defect. +// 2. once a frame is body-only, no later frame may go back to showing +// narration. +// +// Enable with REASONING_CORPUS=. +func TestReplayProductionCorpusThroughFeed(t *testing.T) { + path := os.Getenv("REASONING_CORPUS") + if path == "" { + t.Skip("set REASONING_CORPUS to replay production answers") + } + file, err := os.Open(path) + if err != nil { + t.Fatalf("open corpus: %v", err) + } + defer file.Close() + + chunkSizes := []int{11, 47, 199, 1001} + var rows, frames, bodyFrames int + type violation struct { + id string + size int + marks bool + prefix int + bytes int + } + var violations []violation + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 1<<20), 1<<22) + for scanner.Scan() { + var row struct { + ID string `json:"id"` + Content string `json:"content"` + } + if err := json.Unmarshal(scanner.Bytes(), &row); err != nil { + t.Fatalf("decode row: %v", err) + } + rows++ + for _, size := range chunkSizes { + for _, marks := range []bool{false, true} { + feed := NewPartialFeed("reasoning") + bodySeen := false + for start := 0; start < len(row.Content); start += size { + end := start + size + if end > len(row.Content) { + end = len(row.Content) + } + frame := feed.OnAnswer(row.Content[:end]) + frames++ + if firstStructuralLine(frame) == "" { + // no body in this frame yet: narration only, expected. + continue + } + offset, ok := BodyStartOffset(frame) + prefix := -1 + if ok { + prefix = len([]rune(frame[:offset])) + } + if ok && prefix == 0 { + bodySeen = true + bodyFrames++ + continue + } + // The frame carries a structural line but does not start at + // it: narration is still visible above the body. + violations = append(violations, violation{ + id: row.ID, + size: size, + marks: marks, + prefix: prefix, + bytes: len(frame), + }) + _ = bodySeen + } + } + } + } + if err := scanner.Err(); err != nil { + t.Fatalf("scan corpus: %v", err) + } + t.Logf("rows=%d frames=%d body_only_frames=%d narration_above_body_frames=%d", rows, frames, bodyFrames, len(violations)) + for i, v := range violations { + if i >= 8 { + t.Logf("... %d more", len(violations)-i) + break + } + t.Logf("violation id=%s chunk=%d step_boundaries=%t body_prefix_runes=%d bytes=%d", v.id, v.size, v.marks, v.prefix, v.bytes) + } + if len(violations) > 0 { + t.Errorf("%d frames showed narration above the answer body", len(violations)) + } +} \ No newline at end of file