Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .abcd/development/brief/04-surfaces/03-merge.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ alongside the manifest.
session-relative); interactions enter as `src: "event"` entries with epoch
milliseconds converted via `t0_epoch_ms`, and are assigned sequential
`ev-NNN` IDs at merge time.
- When interactions are present, `t0_epoch_ms` is required: without it the
epoch-millisecond interaction times cannot be placed on the session clock, so
merge rejects the session rather than emit a silently corrupt timeline. A
transcript-only session (no interactions) is already session-relative and is
unaffected.
- Entries are stably sorted by time and written one JSON object per line;
schema in [`../05-internals/02-schemas.md`](../05-internals/02-schemas.md).
- Prints the count: `merged N utterances + M events → <dir>/timeline.jsonl`.
12 changes: 12 additions & 0 deletions .abcd/work/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,15 @@ Architecture-shaping decisions graduate to an ADR under
newline-less prefix) can no longer leave a partial JSONL line that corrupts one
physical record and breaks merge's reader; corrected the false comment claiming
`os.File.Write` gives newline atomicity.
- 2026-07-18 — `analyze.indexTimeline` seeds `idx.end` on the first entry (`i == 0
|| end > idx.end`), matching how `idx.start` is seeded, so a fully-negative
timeline (an external recording predating manifest t0) reports its true latest
(still-negative) entry end as `sessionEnd` instead of flooring it at the zero
value 0. Fixes an over-permissive finding-time upper bound that admitted a
finding anchored after the real session end.
- 2026-07-18 — `timeline.Merge` rejects a session that has interactions but a
manifest lacking `t0_epoch_ms` (T0EpochMS == 0), since epoch-millisecond
interaction times cannot be placed on the session clock without it; previously
it used the zero-value anchor and wrote a silently corrupt timeline (~55-year
offsets, nonsense report duration) with exit 0. Transcript-only sessions are
unaffected.
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ testimony merge -session DIR
|---|---|---|
| `-session` | *(required)* | session directory |

Behaviour: reads `manifest.json` (required), `transcript.jsonl`, and `interactions.jsonl`; converts interaction epoch-millisecond times to session-relative seconds via `t0_epoch_ms`; writes the time-sorted `timeline.jsonl`; prints `merged N utterances + M events → <path>`. A missing `transcript.jsonl` or `interactions.jsonl` counts as zero records rather than an error, so a default audio-only `record` session (which never writes `interactions.jsonl`) still merges to a speech-only timeline.
Behaviour: reads `manifest.json` (required), `transcript.jsonl`, and `interactions.jsonl`; converts interaction epoch-millisecond times to session-relative seconds via `t0_epoch_ms`; writes the time-sorted `timeline.jsonl`; prints `merged N utterances + M events → <path>`. A missing `transcript.jsonl` or `interactions.jsonl` counts as zero records rather than an error, so a default audio-only `record` session (which never writes `interactions.jsonl`) still merges to a speech-only timeline. When interactions are present, `t0_epoch_ms` is required: without it their epoch-millisecond times cannot be placed on the session clock, so merge fails rather than write a corrupt timeline.

## `testimony report`

Expand Down
20 changes: 20 additions & 0 deletions internal/analyze/analyze_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,26 @@ func TestIngestAcceptsNegativeAnchoredFinding(t *testing.T) {
}
}

// TestIngestRejectsFindingAfterNegativeSessionEnd is the loose-upper-bound
// regression: when every timeline entry sits at negative session-relative time
// (an external recording predating manifest t0), the session end is the latest
// still-negative entry, not 0. Pre-fix indexTimeline seeded idx.end from the zero
// value 0 and only grew it on `end > idx.end`, so a fully-negative timeline
// reported its end as 0 and admitted a finding anchored after the real session
// end. negativeTimeline's latest end is utt-004's t1 = -1, so t = -0.5 is after
// the session ended and must be rejected.
func TestIngestRejectsFindingAfterNegativeSessionEnd(t *testing.T) {
dir := writeSession(t, negativeTimeline)
answer := `{"rubric":"testimony-analysis/v1","findings":[
{"id":"F-001","t":-0.5,"type":"bug","severity":3,"quote":"I clicked save and nothing happened",
"evidence":["utt-004","ev-003"],"status":"unverified"}
]}`
_, err := Ingest(dir, strings.NewReader(answer))
if err == nil || !strings.Contains(err.Error(), "outside the session") {
t.Fatalf("expected an out-of-range refusal for t after the negative session end, got %v", err)
}
}

// TestIngestRefusesEmptyFindings is the data-loss regression: an empty findings
// array must not truncate a prior good findings.jsonl. Pre-fix Ingest wrote an
// empty slice with O_TRUNC and reported success.
Expand Down
7 changes: 6 additions & 1 deletion internal/analyze/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ func indexTimeline(entries []timeline.Entry) timelineIndex {
idx.routes[r] = true
}
}
if end > idx.end {
// Seed idx.end on the first entry, exactly as idx.start is seeded below, so
// the upper bound reflects the true maximum end even when every entry sits
// at negative session-relative time (a recording predating t0, audio-only).
// Growing from the zero value 0 would floor the end at 0 and admit a finding
// anchored after the real (negative) session end.
if i == 0 || end > idx.end {
idx.end = end
}
// Track the earliest entry start so the finding-time lower bound matches
Expand Down
10 changes: 10 additions & 0 deletions internal/timeline/timeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,16 @@ func Merge(dir string) (speech, events int, err error) {
return 0, 0, fmt.Errorf("read interactions: %w", err)
}

// Interaction times are epoch milliseconds; without t0 they cannot be placed
// on the session clock. A manifest lacking t0_epoch_ms leaves T0EpochMS at the
// zero value, which would turn each epoch-ms timestamp into a ~55-year offset
// and write a silently corrupt timeline. Reject it rather than emit nonsense.
// A transcript-only session carries no interactions and is already
// session-relative, so it is unaffected.
if len(ints) > 0 && man.T0EpochMS == 0 {
return 0, 0, fmt.Errorf("manifest is missing t0_epoch_ms; cannot place interactions on the session clock")
}

entries := BuildEntries(man.T0EpochMS, utts, ints)
if err := session.WriteJSONL(filepath.Join(dir, session.TimelineFile), entries); err != nil {
return 0, 0, fmt.Errorf("write timeline: %w", err)
Expand Down
25 changes: 25 additions & 0 deletions internal/timeline/timeline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package timeline
import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/REPPL/Testimony/internal/session"
Expand Down Expand Up @@ -74,6 +75,30 @@ func TestMergeAudioOnlySession(t *testing.T) {
}
}

// TestMergeRejectsMissingT0WithInteractions is the missing-anchor regression: a
// session with interactions.jsonl but a manifest lacking t0_epoch_ms cannot place
// those epoch-ms interaction times on the session clock. Pre-fix Merge used the
// zero-value t0 (0), turning each epoch-ms timestamp into a ~55-year offset and
// writing a silently corrupt timeline while exiting 0. Merge must reject it.
func TestMergeRejectsMissingT0WithInteractions(t *testing.T) {
dir := t.TempDir()
// Manifest without t0_epoch_ms (left at the zero value).
if err := session.SaveManifest(dir, session.Manifest{Session: "s"}); err != nil {
t.Fatalf("SaveManifest: %v", err)
}
ints := []Interaction{{T: t0 + 9_500, Kind: "click", Selector: "[data-testid=save-btn]"}}
if err := session.WriteJSONL(filepath.Join(dir, session.InteractionsFile), ints); err != nil {
t.Fatalf("write interactions: %v", err)
}
if _, _, err := Merge(dir); err == nil || !strings.Contains(err.Error(), "t0_epoch_ms") {
t.Fatalf("expected a missing-t0 error, got %v", err)
}
// The corrupt timeline must not have been written.
if _, statErr := os.Stat(filepath.Join(dir, session.TimelineFile)); statErr == nil {
t.Fatalf("timeline.jsonl was written despite the missing t0")
}
}

func TestEventsNearWindow(t *testing.T) {
entries := sample()
var speech []Entry
Expand Down