Investigate timeline solver cycle and add trigger conflict validation

- Add trigger conflict validation in show.go to detect overlapping or
  mutually exclusive row constraints.
- Implement Trigger.String() for improved error reporting.
- Add detailed logging to timeline.go to trace iterative solver
  non-convergence and empty row optimization cycles.
- Extend mock show generator with experimental cross-track triggers.
This commit is contained in:
Ian Gulliver
2026-02-21 19:14:14 -08:00
parent 4e42b6ea60
commit 34747e3ff8
3 changed files with 71 additions and 6 deletions

View File

@@ -80,11 +80,22 @@ func GenerateMockShow(numTracks, numScenes, avgCuesPerScene, avgBlocksPerCue int
chainFromByTrack := make(map[int]*Block) chainFromByTrack := make(map[int]*Block)
needsEnd := make(map[string]*Block) needsEnd := make(map[string]*Block)
triggerIdx := make(map[TriggerSource]*Trigger)
allowedTracks := make(map[int]bool, numTracks) allowedTracks := make(map[int]bool, numTracks)
for i := range numTracks { for i := range numTracks {
allowedTracks[i] = true allowedTracks[i] = true
} }
addTrigger := func(source TriggerSource, target TriggerTarget) {
if t := triggerIdx[source]; t != nil {
t.Targets = append(t.Targets, target)
return
}
t := &Trigger{Source: source, Targets: []TriggerTarget{target}}
show.Triggers = append(show.Triggers, t)
triggerIdx[source] = t
}
for scene := 1; scene <= numScenes; scene++ { for scene := 1; scene <= numScenes; scene++ {
cuesInScene := 1 + rng.IntN(avgCuesPerScene*2) cuesInScene := 1 + rng.IntN(avgCuesPerScene*2)
@@ -126,11 +137,27 @@ func GenerateMockShow(numTracks, numScenes, avgCuesPerScene, avgBlocksPerCue int
block := randBlock(trackIdx) block := randBlock(trackIdx)
show.Blocks = append(show.Blocks, block) show.Blocks = append(show.Blocks, block)
if prev := chainFromByTrack[trackIdx]; prev != nil { if prev := chainFromByTrack[trackIdx]; prev != nil {
show.Triggers = append(show.Triggers, &Trigger{ addTrigger(
Source: TriggerSource{Block: prev.ID, Signal: "END"}, TriggerSource{Block: prev.ID, Signal: "END"},
Targets: []TriggerTarget{{Block: block.ID, Hook: "START"}}, TriggerTarget{Block: block.ID, Hook: "START"},
}) )
delete(needsEnd, prev.ID) delete(needsEnd, prev.ID)
} else if rng.Float64() < 0.3 {
var crossSrc *Block
for ti, blk := range chainFromByTrack {
if ti != trackIdx && blk != nil && needsEnd[blk.ID] == nil {
crossSrc = blk
break
}
}
if crossSrc != nil {
addTrigger(
TriggerSource{Block: crossSrc.ID, Signal: "END"},
TriggerTarget{Block: block.ID, Hook: "START"},
)
} else {
cueTargets = append(cueTargets, TriggerTarget{Block: block.ID, Hook: "START"})
}
} else { } else {
cueTargets = append(cueTargets, TriggerTarget{Block: block.ID, Hook: "START"}) cueTargets = append(cueTargets, TriggerTarget{Block: block.ID, Hook: "START"})
} }

View File

@@ -26,6 +26,14 @@ type Trigger struct {
Targets []TriggerTarget `json:"targets"` Targets []TriggerTarget `json:"targets"`
} }
func (t *Trigger) String() string {
s := fmt.Sprintf("%s/%s ->", t.Source.Block, t.Source.Signal)
for _, target := range t.Targets {
s += fmt.Sprintf(" %s/%s", target.Block, target.Hook)
}
return s
}
type TriggerSource struct { type TriggerSource struct {
Block string `json:"block"` Block string `json:"block"`
Signal string `json:"signal"` Signal string `json:"signal"`
@@ -92,11 +100,33 @@ func (show *Show) Validate() error {
hookTargeted := map[blockEvent]bool{} hookTargeted := map[blockEvent]bool{}
startTargeted := map[string]bool{} startTargeted := map[string]bool{}
sourceUsed := map[blockEvent]bool{} sourceUsed := map[blockEvent]bool{}
signalTargetedBy := map[blockEvent]*Trigger{}
for _, trigger := range show.Triggers {
for _, target := range trigger.Targets {
signalTargetedBy[blockEvent{target.Block, target.Hook}] = trigger
}
}
for _, trigger := range show.Triggers { for _, trigger := range show.Triggers {
sourceBlock := blocksByID[trigger.Source.Block] sourceBlock := blocksByID[trigger.Source.Block]
if sourceBlock == nil { if sourceBlock == nil {
return fmt.Errorf("trigger source block %q not found", trigger.Source.Block) return fmt.Errorf("trigger source block %q not found", trigger.Source.Block)
} }
targetedTracks := map[string]string{}
for _, target := range trigger.Targets {
targetBlock := blocksByID[target.Block]
if prev, ok := targetedTracks[targetBlock.Track]; ok {
return fmt.Errorf("trigger conflict: %s targets multiple blocks on track %q (%q and %q)",
trigger, targetBlock.Track, prev, target.Block)
}
targetedTracks[targetBlock.Track] = target.Block
}
if t, ok := signalTargetedBy[blockEvent{trigger.Source.Block, trigger.Source.Signal}]; ok {
return fmt.Errorf("trigger conflict: %s vs %s", t, trigger)
}
if !isValidEventForBlock(sourceBlock, trigger.Source.Signal) { if !isValidEventForBlock(sourceBlock, trigger.Source.Signal) {
return fmt.Errorf("trigger source signal %q is invalid for block %q", trigger.Source.Signal, trigger.Source.Block) return fmt.Errorf("trigger source signal %q is invalid for block %q", trigger.Source.Signal, trigger.Source.Block)
} }

View File

@@ -257,13 +257,16 @@ func (tl *Timeline) buildConstraints() {
} }
func (tl *Timeline) assignRows() error { func (tl *Timeline) assignRows() error {
for range 1000000 { for i := range 1000000 {
if tl.enforceConstraints() { if tl.enforceConstraints() {
continue continue
} }
if tl.enforceExclusives() { if tl.enforceExclusives() {
continue continue
} }
if i > 0 {
fmt.Printf("assignRows: converged in %d iterations\n", i)
}
return nil return nil
} }
for _, c := range tl.constraints { for _, c := range tl.constraints {
@@ -284,6 +287,7 @@ func (tl *Timeline) enforceConstraints() bool {
if c.satisfied() { if c.satisfied() {
continue continue
} }
fmt.Printf("enforceConstraints: unsatisfied %s\n", c)
switch c.kind { switch c.kind {
case constraintSameRow: case constraintSameRow:
if c.a.row < c.b.row { if c.a.row < c.b.row {
@@ -304,6 +308,7 @@ func (tl *Timeline) enforceExclusives() bool {
if g.satisfied(tl.Tracks) { if g.satisfied(tl.Tracks) {
continue continue
} }
fmt.Printf("enforceExclusives: unsatisfied %s\n", g)
row := g.members[0].row row := g.members[0].row
memberTracks := map[*TimelineTrack]bool{} memberTracks := map[*TimelineTrack]bool{}
for _, m := range g.members { for _, m := range g.members {
@@ -316,6 +321,7 @@ func (tl *Timeline) enforceExclusives() bool {
if !t.cellTypeAt(row, CellEvent, CellTitle, CellSignal) { if !t.cellTypeAt(row, CellEvent, CellTitle, CellSignal) {
continue continue
} }
fmt.Printf("enforceExclusives: inserting gap in track %s at row %d\n", t.ID, row)
tl.insertGap(t, row) tl.insertGap(t, row)
return true return true
} }
@@ -344,6 +350,7 @@ func (tl *Timeline) isAllRemovableGapRow(row int, except *TimelineTrack) bool {
} }
func (tl *Timeline) removeGapAt(track *TimelineTrack, index int) { func (tl *Timeline) removeGapAt(track *TimelineTrack, index int) {
fmt.Printf("removeGapAt: track %s row %d\n", track.ID, index)
track.Cells = append(track.Cells[:index], track.Cells[index+1:]...) track.Cells = append(track.Cells[:index], track.Cells[index+1:]...)
tl.reindexRowsFrom(track, index) tl.reindexRowsFrom(track, index)
} }
@@ -355,8 +362,9 @@ func (tl *Timeline) reindexRowsFrom(track *TimelineTrack, start int) {
} }
func (tl *Timeline) insertGap(track *TimelineTrack, beforeIndex int) { func (tl *Timeline) insertGap(track *TimelineTrack, beforeIndex int) {
fmt.Printf("insertGap: track %s before %d\n", track.ID, beforeIndex)
if tl.isAllRemovableGapRow(beforeIndex, track) { if tl.isAllRemovableGapRow(beforeIndex, track) {
fmt.Printf("insertGap: found removable gap row at %d\n", beforeIndex)
for _, t := range tl.Tracks { for _, t := range tl.Tracks {
if t == track { if t == track {
continue continue