Repository: vega/altair

Issue:
Use object specific counter instead of a global counter for unnamed parameter & view names
### What is your suggestion?

Unnamed parameters and sub-charts are automatically named by using a global counter:

Parameter:

https://github.com/vega/altair/blob/f345cd9368ae2bbc98628e9245c93fa9fb582621/altair/vegalite/v5/api.py#L196-L201

Subcharts/Views:

https://github.com/vega/altair/blob/f345cd9368ae2bbc98628e9245c93fa9fb582621/altair/vegalite/v5/api.py#L2885-L2890

This will lead to different specs for the same charts in a multi-threaded/multi-user environment (e.g., Streamlit). In Streamlit, we rely on the spec to be stable to calculate a deterministic element identity across app reruns. Having this global counter makes the usage of Altair complicated. It would be awesome if the global counter would be replaced with a counter that's specific to the chart object -> which would make the chart spec stable independent of the global state.








### Have you considered any alternative solutions?

_No response_


Discussion:
Hi @LukasMasuch, thanks for the report.

Unfortunately it's not quite as straightforward as moving the counters from class-level to instance-level. The purpose of these is to make sure that subcharts and params have unique names across concatenation.

For example:
```python
chart1.to_dict()
chart2.to_dict()
(chart1 | chart2).to_dict()
```
In the result of `(chart1 | chart2).to_dict()`, the params associated with `chart1` must be named differently than those associated with `chart2`.

To avoid a global counter, we might need to refactor to delay assigning these names until the conversion to a dictionary.

@jonmmease Thanks for the quick response :) 

> To avoid a global counter, we might need to refactor to delay assigning these names until the conversion to a dictionary.

This sounds like a reasonable solution. 

Our temporary solution in Streamlit will probably be some hacky replacements in the vega-lite spec, but it would be much better if it were eventually solved with a counter that is applied only in the `to_dict()` logic.
It's the same issue with data referencing, that one was stabilized by using the hash value of the dataset. We might be able to do this by introducing a hash method on the chart specification, and use that somehow when referencing views and params. Thanks for raising!
I agree, it would be cleaner and useful for web apps if there is no global counter. We have the same issue in [dash-vega-components](https://github.com/vega/dash-vega-components/blob/main/src/lib/fragments/Vega.react.js#L29) (although less severe as there is no "rerun" of the whole app) and maybe there are other use cases which would benefit from this too.

I'm worried that delaying the parameter naming breaks user code. In the documentation, and many examples on other sites, use the pattern of accessing parameter names in expressions via the `.name` attribute. Search for `.name` in https://altair-viz.github.io/user_guide/interactions.html, e.g.:

```python
bind_range = alt.binding_range(min=100, max=300, name='Slider value:  ')
param_width = alt.param(bind=bind_range)

# Examples of how to write both js and python expressions
param_color_js_expr = alt.param(expr=f"{param_width.name} < 200 ? 'red' : 'black'")
```

So I think names already need to be available when a parameter is instantiated. We could port the fix of @LukasMasuch in https://github.com/streamlit/streamlit/pull/8628. Doing this on the JSON string feels hacky but I also can't come up with something better, see https://github.com/streamlit/streamlit/pull/8628/files#diff-611496cd0d1726cf41aed099fd6723e35d40833db11afbafef9be914fc425f26R270 for reasoning on why working on the dict does not work. Same goes for chart objects. Basically, parameters can just be referenced in too many places and even in strings as shown above and hence the replacement needs to happen on strings anyway.

It could be a new argument to `to_dict` which by default should be `False` as it slows down the conversion and would be a breaking change. `chart.to_dict(reset_unnamed_parameter_names=True`)` (need a better name for it) would do the following:
* `chart.to_json()`
* The fix from @LukasMasuch 
* `return json.loads(fixed_spec)` <- this can be skipped if `to_json` was called which is what Streamlit would need.

For Streamlit, not much would change compared to the current fix apart from that it's something officially supported and maintained in Altair, but the upside would be that it's also available for other libraries.

Thoughts?
Thanks @binste, I like the solution of adding an optional argument to `to_dict`/`to_json`. Maybe something like `stable_names=True`?
Streamlit has to introduce custom code that utilise internal altair functions. This might be one of reasons that https://github.com/vega/altair/issues/3554 is popping up as an issue. I say, might, because this specific issue might not be related. 
But if we want to restructure the altair internal code, it is a good idea to first address this issue so we produce vega-lite output of altair specifications which are stable when they include parameters.
> Streamlit has to introduce custom code that utilise internal altair functions. This might be one of reasons that [#3554](https://github.com/vega/altair/issues/3554) is popping up as an issue. I say, might, because this specific issue might not be related.

We found a different source for that particular bug, which was fixed in #3637 

(*@mattijn you can ignore this, just making sure it is recorded here*)



Patch:
diff --git a/altair/utils/_transformed_data.py b/altair/utils/_transformed_data.py
index 350547425..2d22b94e6 100644
--- a/altair/utils/_transformed_data.py
+++ b/altair/utils/_transformed_data.py
@@ -163,6 +163,46 @@ def transformed_data(chart, row_limit=None, exclude=None):
 # a very consistent behavior for overloaded functions.
 # The same error appeared when trying it with Protocols for the concat and layer charts.
 # This function is only used internally and so we accept this inconsistency for now.
+def _assign_chart_name(chart: ChartType) -> None:
+    """Assign a name to a chart if it doesn't have one."""
+    if chart.name in {None, Undefined}:
+        # Use hash-based naming for Altair Chart objects
+        if hasattr(chart, "_get_view_hash_name"):
+            chart.name = chart._get_view_hash_name()
+        else:
+            # For Vega-Lite schema objects (UnitSpec, FacetedUnitSpec, etc.),
+            # use simple naming since these are already unique by design
+            chart_type = chart.__class__.__name__.lower()
+            # Clean up the type name for readability
+            chart_type = (
+                chart_type.replace("spec", "")
+                .replace("generic", "")
+                .replace("concat", "")
+            )
+            chart_type = chart_type.removesuffix("_")
+            # Use object ID for uniqueness - these objects are already unique
+            chart.name = f"view_{chart_type}_{id(chart):x}"
+
+
+def _get_subcharts(chart: ChartType) -> list[Any]:
+    """Get the subcharts for a composite chart."""
+    if isinstance(chart, _chart_class_mapping[LayerChart]):
+        return chart.layer
+    elif isinstance(chart, _chart_class_mapping[HConcatChart]):
+        return chart.hconcat
+    elif isinstance(chart, _chart_class_mapping[VConcatChart]):
+        return chart.vconcat
+    elif isinstance(chart, _chart_class_mapping[ConcatChart]):
+        return chart.concat
+    else:
+        msg = (
+            "transformed_data accepts an instance of "
+            "Chart, FacetChart, LayerChart, HConcatChart, VConcatChart, or ConcatChart\n"
+            f"Received value of type: {type(chart)}"
+        )
+        raise ValueError(msg)
+
+
 def name_views(
     chart: ChartType, i: int = 0, exclude: Iterable[str] | None = None
 ) -> list[str]:
@@ -190,39 +230,23 @@ def name_views(
         List of the names of the charts and subcharts
     """
     exclude = set(exclude) if exclude is not None else set()
+
+    # Handle simple charts (Chart and FacetChart)
     if isinstance(
         chart, (_chart_class_mapping[Chart], _chart_class_mapping[FacetChart])
     ):
         if chart.name not in exclude:
-            if chart.name in {None, Undefined}:
-                # Add name since none is specified
-                chart.name = Chart._get_name()
+            _assign_chart_name(chart)
             return [chart.name]
-        else:
-            return []
-    else:
-        subcharts: list[Any]
-        if isinstance(chart, _chart_class_mapping[LayerChart]):
-            subcharts = chart.layer
-        elif isinstance(chart, _chart_class_mapping[HConcatChart]):
-            subcharts = chart.hconcat
-        elif isinstance(chart, _chart_class_mapping[VConcatChart]):
-            subcharts = chart.vconcat
-        elif isinstance(chart, _chart_class_mapping[ConcatChart]):
-            subcharts = chart.concat
-        else:
-            msg = (
-                "transformed_data accepts an instance of "
-                "Chart, FacetChart, LayerChart, HConcatChart, VConcatChart, or ConcatChart\n"
-                f"Received value of type: {type(chart)}"
-            )
-            raise ValueError(msg)
-
-        chart_names: list[str] = []
-        for subchart in subcharts:
-            for name in name_views(subchart, i=i + len(chart_names), exclude=exclude):
-                chart_names.append(name)
-        return chart_names
+        return []
+
+    # Handle composite charts
+    subcharts = _get_subcharts(chart)
+    chart_names: list[str] = []
+    for subchart in subcharts:
+        for name in name_views(subchart, i=i + len(chart_names), exclude=exclude):
+            chart_names.append(name)
+    return chart_names
 
 
 def get_group_mark_for_scope(
diff --git a/altair/vegalite/v6/api.py b/altair/vegalite/v6/api.py
index 7c505cb83..fc045a24c 100644
--- a/altair/vegalite/v6/api.py
+++ b/altair/vegalite/v6/api.py
@@ -32,6 +32,11 @@
 from .display import VEGA_VERSION, VEGAEMBED_VERSION, VEGALITE_VERSION, renderers
 from .schema import SCHEMA_URL, channels, core, mixins
 from .schema._typing import Map, PrimitiveValue_T, SingleDefUnitChannel_T, Temporal
+from .schema.core import (
+    SelectionParameter,
+    TopLevelSelectionParameter,
+    VariableParameter,
+)
 
 if sys.version_info >= (3, 14):
     from typing import TypedDict
@@ -61,6 +66,12 @@
 
     from altair.utils.core import DataFrameLike
 
+    from .schema.core import (
+        SelectionParameter,
+        TopLevelSelectionParameter,
+        VariableParameter,
+    )
+
     if sys.version_info >= (3, 13):
         from typing import Required, TypeIs
     else:
@@ -131,16 +142,13 @@
         ProjectionType,
         RepeatMapping,
         RepeatRef,
-        SelectionParameter,
         SequenceGenerator,
         SortField,
         SphereGenerator,
         Step,
         TimeUnit,
-        TopLevelSelectionParameter,
         Transform,
         UrlData,
-        VariableParameter,
         Vector2number,
         Vector2Vector2number,
         Vector3number,
@@ -366,12 +374,47 @@ class Parameter(_expr_core.OperatorMixin):
     """A Parameter object."""
 
     _schema: t.ClassVar[_TypeMap[Literal["object"]]] = {"type": "object"}
-    _counter: int = 0
 
-    @classmethod
-    def _get_name(cls) -> str:
-        cls._counter += 1
-        return f"param_{cls._counter}"
+    def _compute_hash(self) -> str:
+        """
+        Compute a deterministic hash of the parameter specification.
+
+        Includes parameter type, configuration, and context to ensure uniqueness.
+        """
+        # Access attributes directly to avoid triggering __getattr__
+        param_type = getattr(self, "param_type", None)
+        empty = getattr(self, "empty", None)
+        param = getattr(self, "param", None)
+
+        # Create a hash data structure with parameter information
+        hash_data = {
+            "param_type": param_type,
+            "empty": empty,
+        }
+
+        if param is not None and hasattr(param, "to_dict"):
+            param_dict = param.to_dict()
+
+            # Create a copy of param_dict without the name field for hash computation
+            # The name should not be part of the hash since it's what we're trying to generate
+            param_dict_for_hash = param_dict.copy()
+            if isinstance(param_dict_for_hash, dict):
+                param_dict_for_hash.pop("name", None)
+
+            hash_data["param"] = param_dict_for_hash
+        else:
+            hash_data["param"] = param
+
+        # Serialize to JSON with sorted keys for consistency
+        hash_json = json.dumps(hash_data, sort_keys=True, default=str)
+
+        # Compute hash and truncate to 16 characters for readability
+        hsh = hashlib.sha256(hash_json.encode()).hexdigest()[:16]
+        return f"param_{hsh}"
+
+    def _get_param_hash_name(self) -> str:
+        """Get a deterministic name based on the parameter specification hash."""
+        return self._compute_hash()
 
     def __init__(
         self,
@@ -382,13 +425,24 @@ def __init__(
         ] = Undefined,
         param_type: Optional[Literal["variable", "selection"]] = Undefined,
     ) -> None:
-        if name is None:
-            name = self._get_name()
-        self.name = name
+        # Set attributes first
         self.empty = empty
         self.param = param
         self.param_type = param_type
 
+        # Generate name after attributes are set
+        if name is None:
+            # Use hash-based naming for deterministic names
+            name = self._get_param_hash_name()
+            self._name_is_hashed = True
+        else:
+            self._name_is_hashed = False
+        self.name = name
+
+        # Update the underlying param object's name to match
+        if self.param is not None and not utils.is_undefined(self.param):
+            self.param.name = self.name
+
     @utils.deprecated(
         version="5.0.0",
         alternative="to_dict",
@@ -1344,6 +1398,36 @@ def value(value: Any, **kwargs: Any) -> _Value:
     return _Value(value=value, **kwargs)  # type: ignore[typeddict-item]
 
 
+def _make_param_obj(
+    temp_name: str,
+    value: Optional[Any],
+    bind: Optional[Binding],
+    expr: Optional[str | Expr | Expression],
+    kwds: dict,
+) -> tuple[
+    VariableParameter | TopLevelSelectionParameter | SelectionParameter,
+    Literal["variable", "selection"],
+]:
+    if "select" in kwds:
+        select = kwds.pop("select")
+        param_obj: (
+            VariableParameter | TopLevelSelectionParameter | SelectionParameter
+        ) = core.SelectionParameter(
+            name=temp_name, select=select, value=value, bind=bind, **kwds
+        )
+        param_type: Literal["variable", "selection"] = "selection"
+    elif "views" in kwds:
+        param_obj = core.TopLevelSelectionParameter(name=temp_name, value=value, **kwds)
+        param_type = "selection"
+    else:
+        param_obj = core.VariableParameter(
+            name=temp_name, value=value, bind=bind, expr=expr, **kwds
+        )
+        param_type = "variable"
+
+    return param_obj, param_type
+
+
 def param(
     name: str | None = None,
     value: Optional[Any] = Undefined,
@@ -1388,54 +1472,56 @@ def param(
     parameter: Parameter
         The parameter object that can be used in chart creation.
     """
-    warn_msg = "The value of `empty` should be True or False."
-    empty_remap = {"none": False, "all": True}
-    parameter = Parameter(name)
-
-    if not utils.is_undefined(empty):
-        if isinstance(empty, bool) and not isinstance(empty, str):
-            parameter.empty = empty
-        elif empty in empty_remap:
-            utils.deprecated_warn(warn_msg, version="5.0.0")
-            parameter.empty = empty_remap[t.cast("str", empty)]
-        else:
-            raise ValueError(warn_msg)
+    temp_name = "__TEMP__"
+    # Always use a string for the name
 
     if _init := kwds.pop("init", None):
-        utils.deprecated_warn("Use `value` instead of `init`.", version="5.0.0")
-        # If both 'value' and 'init' are set, we ignore 'init'.
-        if value is Undefined:
-            kwds["value"] = _init
-
-    # ignore[arg-type] comment is needed because we can also pass _expr_core.Expression
-    if "select" not in kwds:
-        parameter.param = core.VariableParameter(
-            name=parameter.name,
-            bind=bind,
-            value=value,
-            expr=expr,
-            **kwds,
-        )
-        parameter.param_type = "variable"
-    elif "views" in kwds:
-        parameter.param = core.TopLevelSelectionParameter(
-            name=parameter.name, bind=bind, value=value, expr=expr, **kwds
+        utils.deprecated_warn(
+            "The 'init' argument is deprecated. Use 'value' instead.",
+            version="5.0.0",
         )
-        parameter.param_type = "selection"
+        if utils.is_undefined(value):
+            value = _init
+
+    # Create underlying parameter object with temp_name using helper
+    param_obj, param_type = _make_param_obj(temp_name, value, bind, expr, kwds)
+
+    # Construct Parameter with temp_name and param_obj
+    parameter = Parameter(
+        name=temp_name, empty=empty, param=param_obj, param_type=param_type
+    )
+
+    # Set the final name on both Parameter and underlying param object
+    if name is not None:
+        parameter.name = str(name)
+        if parameter.param is not Undefined:
+            param_obj = t.cast(
+                "Union[VariableParameter, TopLevelSelectionParameter, SelectionParameter]",
+                parameter.param,
+            )
+            param_obj.name = str(name)
     else:
-        parameter.param = core.SelectionParameter(
-            name=parameter.name, bind=bind, value=value, expr=expr, **kwds
-        )
-        parameter.param_type = "selection"
+        # Compute hash-based name
+        hash_name = parameter._get_param_hash_name()
+        parameter.name = hash_name
+        parameter._name_is_hashed = True
+        if parameter.param is not Undefined:
+            param_obj = t.cast(
+                "Union[VariableParameter, TopLevelSelectionParameter, SelectionParameter]",
+                parameter.param,
+            )
+            param_obj.name = hash_name
 
     return parameter
 
 
 def _selection(type: Optional[SelectionType_T] = Undefined, **kwds: Any) -> Parameter:
     # We separate out the parameter keywords from the selection keywords
+    # param_kwds_names contains keywords that should be passed to param() function
+    # The remaining kwds are used to create the selection config (PointSelectionConfig, etc.)
 
-    select_kwds = {"name", "bind", "value", "empty", "init", "views"}
-    param_kwds = {key: kwds.pop(key) for key in select_kwds & kwds.keys()}
+    param_kwds_names = {"name", "bind", "value", "empty", "init", "views"}
+    param_kwds = {key: kwds.pop(key) for key in param_kwds_names & kwds.keys()}
 
     select: IntervalSelectionConfig | PointSelectionConfig
     if type == "interval":
@@ -1453,7 +1539,21 @@ def _selection(type: Optional[SelectionType_T] = Undefined, **kwds: Any) -> Para
         msg = """'type' must be 'point' or 'interval'"""
         raise ValueError(msg)
 
-    return param(select=select, **param_kwds)
+    # Extract all expected parameters from param_kwds and pass them as direct parameters
+    name = param_kwds.pop("name", None)
+    value = param_kwds.pop("value", Undefined)
+    bind = param_kwds.pop("bind", Undefined)
+    empty = param_kwds.pop("empty", Undefined)
+    expr = param_kwds.pop("expr", Undefined)
+    return param(
+        select=select,
+        name=name,
+        value=value,
+        bind=bind,
+        empty=empty,
+        expr=expr,
+        **param_kwds,
+    )
 
 
 @utils.deprecated(
@@ -3623,7 +3723,7 @@ def transform_window(
             **Default value:** ``false``
         sort : List(:class:`SortField`)
             A sort field definition for sorting data objects within a window. If two data
-            objects are considered equal by the comparator, they are considered “peer” values of
+            objects are considered equal by the comparator, they are considered "peer" values of
             equal rank. If sort is not specified, the order is undefined: data objects are
             processed in the order they are observed and none are considered peers (the
             ignorePeers parameter is ignored and treated as if set to ``true`` ).
@@ -3961,12 +4061,30 @@ def __init__(
             **kwargs,
         )
 
-    _counter: int = 0
+    def _compute_hash(self) -> str:
+        """Compute a deterministic hash of the chart specification."""
+        # Get data name if available, otherwise use data object
+        data = getattr(self, "data", None)
+        if data is not None and hasattr(data, "name") and data.name is not None:
+            data_for_hash = data.name
+        else:
+            data_for_hash = data
+
+        # Use basic chart attributes for hash computation.
+        hash_data = {
+            "class": self.__class__.__name__,
+            "mark": getattr(self, "mark", None),
+            "encoding": getattr(self, "encoding", None),
+            "data": data_for_hash,
+            "transform": getattr(self, "transform", None),
+        }
+        hash_json = json.dumps(hash_data, sort_keys=True, default=str)
+        hsh = hashlib.sha256(hash_json.encode()).hexdigest()[:16]
+        return f"view_{hsh}"
 
-    @classmethod
-    def _get_name(cls) -> str:
-        cls._counter += 1
-        return f"view_{cls._counter}"
+    def _get_view_hash_name(self) -> str:
+        """Get a deterministic name based on the chart specification hash."""
+        return self._compute_hash()
 
     @classmethod
     def from_dict(
@@ -4075,8 +4193,19 @@ def add_params(self, *params: Parameter) -> Self:
         if copy.params is Undefined:
             copy.params = []
 
-        for s in params:
-            copy.params.append(s.param)
+        # Track existing parameter names
+        existing_names = {param.name for param in copy.params}
+
+        for param in params:
+            if param.name in existing_names:
+                if param._name_is_hashed:
+                    continue  # deduplicate hash-based
+                else:
+                    # If we see a duplicate explicit name, raise
+                    msg = f"Duplicate explicit parameter name: {param.name}"
+                    raise ValueError(msg)
+            copy.params.append(param.param)
+            existing_names.add(param.name)
         return copy
 
     @utils.deprecated(version="5.0.0", alternative="add_params")
@@ -4111,7 +4240,9 @@ def interactive(
             encodings.append("x")
         if bind_y:
             encodings.append("y")
-        return self.add_params(selection_interval(bind="scales", encodings=encodings))
+        return self.add_params(
+            selection_interval(name=name, bind="scales", encodings=encodings)
+        )
 
 
 def _check_if_valid_subspec(
@@ -4994,12 +5125,15 @@ def _combine_subchart_params(  # noqa: C901
 
     subcharts = [subchart.copy() for subchart in subcharts]
 
-    for subchart in subcharts:
+    for i, subchart in enumerate(subcharts):
         if (not hasattr(subchart, "params")) or (utils.is_undefined(subchart.params)):
             continue
 
         if _needs_name(subchart):
-            subchart.name = subchart._get_name()
+            # For concatenated charts, we need unique names even for identical charts
+            # Use the hash as a base but append the position to ensure uniqueness
+            base_name = subchart._get_view_hash_name()
+            subchart.name = f"{base_name}_{i}"
 
         for param in subchart.params:
             p = _prepare_to_lift(param)


Test patch:
diff --git a/tests/vegalite/v6/test_api.py b/tests/vegalite/v6/test_api.py
index 408518662..bf7997afd 100644
--- a/tests/vegalite/v6/test_api.py
+++ b/tests/vegalite/v6/test_api.py
@@ -1019,12 +1019,17 @@ def test_selection():
     assert "or" in (single | multi).to_dict()
     assert "not" in (~single).to_dict()
 
-    # test that default names increment (regression for #1454)
+    # test that default names are deterministic and unique for different types
     sel1 = alt.selection_point()
     sel2 = alt.selection_point()
     sel3 = alt.selection_interval()
     names = {s.name for s in (sel1, sel2, sel3)}
-    assert len(names) == 3
+    # With hash-based naming, identical specifications get the same name
+    # sel1 and sel2 are identical selection_point() calls, so they get the same name
+    # sel3 is a selection_interval(), so it gets a different name
+    assert len(names) == 2
+    assert sel1.name == sel2.name  # Identical specifications get same name
+    assert sel1.name != sel3.name  # Different specifications get different names
 
 
 def test_selection_empty_property_preservation():
@@ -1421,7 +1426,9 @@ def test_add_selection():
         .add_params(selections[0])
         .add_params(selections[1], selections[2])
     )
-    expected = [s.param for s in selections]
+    # The second and third selections are identical (same hash-based name),
+    # so only the first two unique selections should be added
+    expected = [selections[0].param, selections[1].param]
     assert chart.params == expected
 
 
diff --git a/tests/vegalite/v6/test_params.py b/tests/vegalite/v6/test_params.py
index 9037c9121..fa5320ad3 100644
--- a/tests/vegalite/v6/test_params.py
+++ b/tests/vegalite/v6/test_params.py
@@ -104,16 +104,17 @@ def test_parameter_naming():
     prm = alt.param(name="some_name")
     assert prm.param.name == "some_name"
 
-    # test automatic naming which has the form such as param_5
+    # test automatic naming which now uses hash-based naming
     prm0, prm1, prm2 = (alt.param() for _ in range(3))
 
-    res = re.match(r"param_([0-9]+)", prm0.param.name)
+    # Check that all parameters have hash-based names (16 character hex)
+    res = re.match(r"param_([0-9a-f]{16})", prm0.param.name)
+    assert res, f"Expected hash-based name, got: {prm0.param.name}"
 
-    assert res
-
-    num = int(res[1])
-    assert prm1.param.name == f"param_{num + 1}"
-    assert prm2.param.name == f"param_{num + 2}"
+    # With hash-based naming, identical specifications get the same name
+    assert prm0.param.name == prm1.param.name == prm2.param.name, (
+        "Identical parameters should have same hash-based name"
+    )
 
 
 def test_selection_expression():
@@ -232,3 +233,132 @@ def test_creation_views_params_layered_repeat_chart():
 
     dct = c.to_dict()
     assert "child__column_distance_view_" in dct["params"][0]["views"][0]
+
+
+def test_parameter_deduplication():
+    """Test that hash-based parameters are deduplicated to avoid duplicate signal names."""
+    # Test with hash-based naming - these should be deduplicated
+    param1 = alt.param()  # Will get hash-based name
+    param2 = alt.param()  # Will get same hash-based name
+
+    chart = alt.Chart().mark_point().add_params(param1, param2)
+
+    # Check that only one parameter was added
+    assert len(chart.params) == 1
+    assert param1.name == param2.name  # Should have same hash-based name
+    assert chart.params[0].name == param1.name
+
+    # Test that the spec doesn't have duplicate parameter names
+    spec = chart.to_dict()
+    param_names = [p["name"] for p in spec["params"]]
+    assert len(param_names) == len(set(param_names)), (
+        "Duplicate parameter names found in spec"
+    )
+
+
+def test_explicitly_named_parameters_error():
+    """Test that explicitly named parameters with duplicate names raise an error."""
+    # Create two parameters with explicit names - this should raise an error
+    param1 = alt.param(name="my_param_1")
+    param2 = alt.param(name="my_param_1")
+
+    # Create a chart and add both parameters - should raise an error
+    with pytest.raises(
+        ValueError, match="Duplicate explicit parameter name: my_param_1"
+    ):
+        alt.Chart().mark_point().add_params(param1, param2)
+
+
+def test_identical_hash_based_parameters_deduplication():
+    """Test that identical parameters with hash-based names are deduplicated."""
+    from altair.datasets import data
+
+    cars = data.cars.url
+    param_opacity = alt.param(value=1)
+    param_size = alt.param(value=1)
+
+    # Create chart with same parameter added twice
+    chart = (
+        alt.Chart(cars)
+        .mark_circle(opacity=param_opacity, size=param_size)
+        .encode(x="Horsepower:Q", y="Miles_per_Gallon:Q", color="Origin:N")
+        .add_params(param_opacity, param_size)
+    )
+
+    # Check that only one parameter was added
+    assert len(chart.params) == 1
+
+    # Get the spec and verify it has only one parameter
+    spec = chart.to_dict()
+    assert len(spec["params"]) == 1
+
+    # Verify the parameter name is hash-based
+    param_name = spec["params"][0]["name"]
+    assert param_name.startswith("param_")
+    assert len(param_name) == 22  # "param_" + 16 hex chars
+
+    # Verify both opacity and size reference the same parameter
+    assert spec["mark"]["opacity"]["expr"] == param_name
+    assert spec["mark"]["size"]["expr"] == param_name
+
+
+def test_interactive_name_respected():
+    import altair as alt
+    from altair.datasets import data
+
+    cars = data.cars.url
+
+    chart = (
+        alt.Chart(cars)
+        .mark_point()
+        .encode(x="Horsepower:Q", y="Miles_per_Gallon:Q")
+        .interactive(name="MY_CHART")
+    )
+
+    spec = (chart & chart).to_dict()
+    # There should be a single parameter with the name 'MY_CHART'
+    param_names = [p["name"] for p in spec["params"]]
+    assert param_names == ["MY_CHART"], f"Expected ['MY_CHART'], got {param_names}"
+
+    # Check that the parameter has the correct view IDs
+    # The view IDs should be deterministic and consistent across OS/chart types
+    param = spec["params"][0]
+    assert "views" in param, "Parameter should have 'views' field"
+    view_ids = param["views"]
+    assert len(view_ids) == 2, f"Expected 2 view IDs, got {len(view_ids)}"
+
+    # The view IDs should follow the pattern: view_<hash>_<position>
+    # where <hash> is the same for both charts (they're identical)
+    # and <position> is 0 and 1 for the two concatenated charts
+    assert view_ids[0].endswith("_0"), (
+        f"First view ID should end with '_0', got {view_ids[0]}"
+    )
+    assert view_ids[1].endswith("_1"), (
+        f"Second view ID should end with '_1', got {view_ids[1]}"
+    )
+
+    # Both view IDs should have the same base hash name
+    base_name_0 = view_ids[0].rsplit("_", 1)[0]
+    base_name_1 = view_ids[1].rsplit("_", 1)[0]
+    assert base_name_0 == base_name_1, (
+        f"View IDs should have same base name: {base_name_0} vs {base_name_1}"
+    )
+
+    # The base name should start with 'view_' and contain a hex hash
+    assert base_name_0.startswith("view_"), (
+        f"Base name should start with 'view_', got {base_name_0}"
+    )
+    hash_part = base_name_0[5:]  # Remove 'view_' prefix
+    assert len(hash_part) == 16, (
+        f"Hash part should be 16 characters, got {len(hash_part)}: {hash_part}"
+    )
+    assert all(c in "0123456789abcdef" for c in hash_part), (
+        f"Hash part should be hex, got {hash_part}"
+    )
+
+    # For this specific chart configuration, we expect a consistent hash
+    # This ensures the hash is deterministic across different runs/OS
+    expected_base_name = "view_6e7cfb454e831ee6"
+    assert base_name_0 == expected_base_name, (
+        f"Expected base name {expected_base_name}, got {base_name_0}"
+    )