Repository: holoviz/holoviews

Issue:
update_frame throws exception 

Holoviews version : 1.15.4
Python version: 3.10.4

A dynamic update sometimes results in an exception thrown in closest_match:
   if spec[0] == match[0]:
IndexError: tuple index out of range

The reason appears to be that sometimes closest_match is called with a match_spec tuple with a length of 3 instead of the 4 expected by closest_match.

According to the closest_match documentation,  closest_match tries to recursively match type, group, label, and overlay key, i.e., a 4 element tuple.

The match_spec returned in dynamic_update by get_overlay_spec sometimes has only 3 elements, missing the overlay key.  I apologize that I don't have a small example to post that exhibits the behavior.   I believe it may happen when an Overlay initially has a single Curve and more Curves are added dynamically to the Overlay.

The patched code below for dynamic_update in plotting/util.py gives the correct behavior, but is hardly the right solution (I am not a holoviews source developer.  I would love to help more, but I am just a user of the package).   

```
def dynamic_update(plot, subplot, key, overlay, items):
    """
    Given a plot, subplot and dynamically generated (Nd)Overlay
    find the closest matching Element for that plot.
    """
    match_spec = get_overlay_spec(overlay,
                                  wrap_tuple(key),
                                  subplot.current_frame)
    specs = [(i, get_overlay_spec(overlay, wrap_tuple(k), el))
             for i, (k, el) in enumerate(items)]
    # patch to ensure match_spec is a 4 element tuple
    if len(specs):
        if len(match_spec) < len(specs[0][1]):
            match_spec = match_spec + tuple(['XXX']*(len(specs[0][1])-len(match_spec)))
    # end patch

    closest = closest_match(match_spec, specs)
    if closest is None:
        return closest, None, False
    matched = specs[closest][1]
    return closest, matched, match_spec == matched

```

NOTE TRACEBACK LINE NUMBERS BELOW DIFFER FROM PRODUCTION (print statements added to aid in debugging)
  File "/Users/jim/opt/anaconda3/envs/py310/lib/python3.10/site-packages/holoviews/plotting/bokeh/element.py", line 2427, in update_frame
    idx, spec, exact = self._match_subplot(k, subplot, items, element)
  File "/Users/jim/opt/anaconda3/envs/py310/lib/python3.10/site-packages/holoviews/plotting/plot.py", line 1698, in _match_subplot
    idx, spec, exact = dynamic_update(self, subplot, key, element, temp_items)
  File "/Users/jim/opt/anaconda3/envs/py310/lib/python3.10/site-packages/holoviews/plotting/util.py", line 516, in dynamic_update
    closest = closest_match(match_spec, specs)
  File "/Users/jim/opt/anaconda3/envs/py310/lib/python3.10/site-packages/holoviews/core/util.py", line 2237, in closest_match
    return closest_match(match[1:], new_specs, depth)
  File "/Users/jim/opt/anaconda3/envs/py310/lib/python3.10/site-packages/holoviews/core/util.py", line 2237, in closest_match
    return closest_match(match[1:], new_specs, depth)
  File "/Users/jim/opt/anaconda3/envs/py310/lib/python3.10/site-packages/holoviews/core/util.py", line 2237, in closest_match
    return closest_match(match[1:], new_specs, depth)
  File "/Users/jim/opt/anaconda3/envs/py310/lib/python3.10/site-packages/holoviews/core/util.py", line 2218, in closest_match
    if spec[0] == match[0]:
IndexError: tuple index out of range




Discussion:
It looks like a bug, but without a small example, it is hard to be entirely sure. 

Maybe even a video of the interaction to trigger the error could be nice.  
Sorry for the delay.

I was finally able to get a small example with just the right combination to demonstrate the problem.   Here it is:

```
import numpy as np
import panel as pn
import holoviews as hv


def sine(ncurves):
    hv_curves = []
    for i in range(ncurves):
        frequency = 2.0
        xs = np.linspace(0.0, 1.0, 500)
        ys = np.sin((i+1)*frequency*xs)  # just a sine wave

        # First Curve
        curve = hv.Curve((xs,ys), label='A')
        hv_curves.append(curve)

        # second curve, important same label as first curve
        ys = 1.1*np.sin((i+1)*frequency*xs)  # another, bigger amplitude
        curve = hv.Curve((xs,ys), label='A')
        hv_curves.append(curve)

        # third curve, this time, different label
        ys = 1.2*np.sin((i+1)*frequency*xs)  # still another
        curve = hv.Curve((xs,ys), label='B')
        hv_curves.append(curve)
        
    overlay = hv.Overlay(hv_curves)
    return overlay


ncurves = pn.widgets.IntSlider(name='NCurves', start=1, end=2, value=1).servable()
col = pn.Column( hv.DynamicMap(pn.bind(sine, ncurves=ncurves)),
        width=500, height=500).servable()


```
Save to bug.py and Run with:
panel serve --autoreload --show bug.py
Now change the slider of NCurves from 1 to 2.  This should result in the Exception reported originally.

Here is a completely wrong patch to plotting/util.py that will allow the code to continue, but will also print out the offending match_spec and specs that result in the exception.

```
def dynamic_update(plot, subplot, key, overlay, items):
    """
    Given a plot, subplot and dynamically generated (Nd)Overlay
    find the closest matching Element for that plot.
    """
    match_spec = get_overlay_spec(overlay,
                                  wrap_tuple(key),
                                  subplot.current_frame)
    specs = [(i, get_overlay_spec(overlay, wrap_tuple(k), el))
             for i, (k, el) in enumerate(items)]

    # patch to ensure match_spec is a 4 element tuple
    if len(specs):
        if len(match_spec) < len(specs[0][1]):
            print(f"match = {match_spec}")
            print(f"spec = {specs[0]}")
            print("-----")
            # the next line is merely a bandaid, but surely a wrong bandaid
            match_spec = match_spec + tuple(['XXX'])
    # end patch

    closest = closest_match(match_spec, specs)
    if closest is None:
        return closest, None, False
    matched = specs[closest][1]
    return closest, matched, match_spec == matched
```
This results in the following output demonstrating that the match spec and specs have different tuple lengths:

> ERROR
> match = ('Curve', 'Curve', 'B')
> spec = (0, ('Curve', 'Curve', 'B', 'I'))
> -----
> ERROR
> match = ('Curve', 'Curve', 'B')
> spec = (0, ('Curve', 'Curve', 'B', 'I'))
> -----

It appears things go off hinge when an overlay has two curves with a common label and a third curve with a different label.



Resolving patch:
diff --git a/holoviews/core/util.py b/holoviews/core/util.py
--- a/holoviews/core/util.py
+++ b/holoviews/core/util.py
@@ -2204,6 +2204,8 @@ def closest_match(match, specs, depth=0):
     Recursively iterates over type, group, label and overlay key,
     finding the closest matching spec.
     """
+    if len(match) == 0:
+        return None
     new_specs = []
     match_lengths = []
     for i, spec in specs:
@@ -2226,11 +2228,10 @@ def closest_match(match, specs, depth=0):
     elif new_specs:
         depth = depth+1
         return closest_match(match[1:], new_specs, depth)
+    elif depth == 0 or not match_lengths:
+        return None
     else:
-        if depth == 0 or not match_lengths:
-            return None
-        else:
-            return sorted(match_lengths, key=lambda x: -x[1])[0][0]
+        return sorted(match_lengths, key=lambda x: -x[1])[0][0]
 
 
 def cast_array_to_int64(array):


Test patch: