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.