From 5538af925994f659598daabf1deebfddcad4c749 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 12 Aug 2026 18:04:36 -0400 Subject: [PATCH] fix(vegas): stop the heaviest plugin doubling across the cycle seam Smooth Weighted Round-Robin spaces repeats well within a pass, but it schedules the heaviest item first and usually last as well. The strip loops, so those two are neighbours: the marquee showed the same plugin twice running at exactly the one join a within-cycle check cannot see. Observed on a live rig at 28 slots -- gaps of 6, 7, 7, 7 and then 1. Rotating the list does not fix it. Rotation preserves the cyclic order exactly, so it moves where the seam is drawn rather than the adjacency itself; the trailing entry has to be swapped with one from the middle. The first version swapped with the first slot that merely fitted, which undid the spacing this exists to protect -- it moved a repeat from a gap of 7 into a gap of 2, more clumped than the seam had ever been. It now picks the candidate furthest from any other appearance, so the repeat lands in the widest gap. Left alone when no candidate exists. A plugin holding most of the slots has to neighbour itself, and scheduling it is better than refusing to. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- docs/ADVANCED_FEATURES.md | 8 +++- src/vegas_mode/stream_manager.py | 50 +++++++++++++++++++++++++ test/test_vegas_live_weighting.py | 62 +++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/docs/ADVANCED_FEATURES.md b/docs/ADVANCED_FEATURES.md index b7c266d7..d620fef7 100644 --- a/docs/ADVANCED_FEATURES.md +++ b/docs/ADVANCED_FEATURES.md @@ -110,7 +110,13 @@ birds > hockey > baseball ``` 18 slots for 12 plugins. Baseball appears 5 times, hockey 3, everything else -once, and no plugin ever appears twice in a row. +once, and no plugin ever appears twice in a row — **including across the seam** +where the cycle loops back on itself. Smooth Weighted Round-Robin schedules the +heaviest item first and usually last as well, so the strip would otherwise show +it twice running at exactly the one join a within-cycle check cannot see. The +trailing repeat is moved into the widest remaining gap. Where a double is +unavoidable — a plugin holding most of the slots has to neighbour itself — the +schedule is left as it is. #### Where the weight comes from diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index 080aa9ec..77d9ffb9 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -487,12 +487,62 @@ class StreamManager: current[picked] -= total schedule.append(picked) + schedule = self._unclump_seam(schedule) + boosted = {p: w for p, w in weights.items() if w > 1} logger.info( "Vegas rotation weighted: %d slots for %d plugins (boosted: %s)", len(schedule), len(ordered), boosted) return schedule + @staticmethod + def _unclump_seam(schedule: List[str]) -> List[str]: + """Stop the heaviest plugin sitting on both ends of the cycle. + + Smooth Weighted Round-Robin spaces repeats well *within* a pass, but + it schedules the heaviest item first and often last too. The strip + loops, so those two are neighbours: the one place the marquee shows + the same plugin twice running is the seam between cycles. + + Rotating the list cannot fix this. Rotation preserves the cyclic order + exactly, so it only moves where the seam is drawn, not the adjacency + itself. The trailing entry has to be swapped with one from the middle + whose neighbours differ from it, which breaks the pair without + creating another. + + Left alone when no such position exists -- a rotation short enough or + lopsided enough to have none is one where the plugin is unavoidably + adjacent to itself anyway. + """ + if len(schedule) < 3 or schedule[0] != schedule[-1]: + return schedule + + repeated = schedule[-1] + size = len(schedule) + elsewhere = [i for i, p in enumerate(schedule[:-1]) if p == repeated] + + def clearance(j: int) -> int: + """Cyclic distance from j to the nearest other appearance.""" + return min(min((i - j) % size, (j - i) % size) for i in elsewhere) + + candidates = [ + j for j in range(1, size - 1) + if schedule[j] != repeated + and schedule[j - 1] != repeated + and schedule[j + 1] != repeated + ] + if not candidates: + return schedule + + # Drop it into the widest gap rather than the first slot that fits. + # Taking the first one undoes the spacing this whole function exists + # to protect: on a 28-slot rotation it moved a repeat from a gap of 7 + # to a gap of 2, which is more clumped than the seam ever was. + best = max(candidates, key=clearance) if elsewhere else candidates[0] + schedule = list(schedule) + schedule[best], schedule[-1] = schedule[-1], schedule[best] + return schedule + def _prefetch_content(self, count: int = 1) -> None: """ Prefetch content for upcoming plugins. diff --git a/test/test_vegas_live_weighting.py b/test/test_vegas_live_weighting.py index 73fe4b26..154a5fc6 100644 --- a/test/test_vegas_live_weighting.py +++ b/test/test_vegas_live_weighting.py @@ -160,6 +160,68 @@ class TestTheSchedule: schedule = sm._apply_priority_weights(order) assert set(schedule) == set(order), set(order) - set(schedule) + def test_nothing_doubles_across_the_cycle_seam(self): + # The strip loops, so the last slot neighbours the first. Smooth + # Weighted Round-Robin schedules the heaviest item first and often + # last too, which put the one clump the algorithm exists to avoid at + # the one place a within-cycle check cannot see. + order = ['baseball', 'weather', 'geochron', 'flights', 'stocks', + 'oftheday', 'youtube', 'stocknews', 'leaderboard', + 'countdown', 'odds', 'f1', 'football', 'music'] + plugins = {p: FakePlugin() for p in order} + plugins['baseball'] = FakePlugin(live=True, declared=5) + plugins['football'] = FakePlugin(live=True, declared=3) + schedule = _manager(plugins)._apply_priority_weights(order) + + n = len(schedule) + doubles = [schedule[i] for i in range(n) + if schedule[i] == schedule[(i + 1) % n]] + assert not doubles, "%r repeats across the seam in %r" % (doubles, schedule) + + def test_the_seam_repair_keeps_every_slot(self): + order = ['a', 'b', 'c', 'd', 'e', 'f'] + plugins = {p: FakePlugin() for p in order} + plugins['a'] = FakePlugin(live=True, declared=4) + schedule = _manager(plugins)._apply_priority_weights(order) + assert _counts(schedule)['a'] == 4, _counts(schedule) + assert sorted(schedule) == sorted( + ['a'] * 4 + ['b', 'c', 'd', 'e', 'f']), schedule + + def test_the_repair_uses_the_widest_gap(self): + # Moving the trailing repeat into the first slot that merely fits + # undoes the spacing: on a 28-slot rotation that turned a gap of 7 + # into a gap of 2, which is more clumped than the seam ever was. + order = ['a'] + ['p%d' % i for i in range(13)] + plugins = {p: FakePlugin() for p in order} + plugins['a'] = FakePlugin(live=True, declared=4) + schedule = _manager(plugins)._apply_priority_weights(order) + at = [i for i, p in enumerate(schedule) if p == 'a'] + gaps = [b - a for a, b in zip(at, at[1:])] + gaps.append(len(schedule) - at[-1] + at[0]) + ideal = len(schedule) / len(at) + assert min(gaps) >= ideal / 2, "gaps %r for ideal %.1f" % (gaps, ideal) + + def test_an_unavoidable_double_is_left_alone(self): + # Five of seven slots are the same plugin, so it must neighbour + # itself. Better to schedule it than to refuse or loop forever. + order = ['a', 'b', 'c'] + plugins = {p: FakePlugin() for p in order} + plugins['a'] = FakePlugin(live=True, declared=5) + schedule = _manager(plugins)._apply_priority_weights(order) + assert _counts(schedule) == {'a': 5, 'b': 1, 'c': 1}, _counts(schedule) + assert set(schedule) == {'a', 'b', 'c'} + + def test_a_schedule_too_short_to_repair_is_returned_as_is(self): + sm = _manager({}) + assert sm._unclump_seam(['a', 'a']) == ['a', 'a'] + assert sm._unclump_seam(['a']) == ['a'] + assert sm._unclump_seam([]) == [] + + def test_a_schedule_with_no_seam_clash_is_untouched(self): + sm = _manager({}) + plain = ['a', 'b', 'c', 'a', 'd'] + assert sm._unclump_seam(plain) == plain + def test_repeats_are_spread_not_clumped(self): # The point of Smooth Weighted Round-Robin. Three-in-a-row followed by # a long silence would be worse than not boosting at all.