Repository: scikit-hep/pyhf

Issue:
Making workspaces immutable?
# Question

Currently workspaces are mutable:

```python
import pyhf
spec = {
    "channels": [
        {
            "name": "Signal Region",
            "samples": [
                {
                    "data": [10],
                    "modifiers": [
                        {"data": [1], "name": "staterror_Signal-Region", "type": "staterror"},
                        {"data": None, "name": "Signal strength",  "type": "normfactor"},
                    ],
                    "name": "Signal",
                }
            ],
        }
    ],
    "measurements": [
        {
            "config": {
                "parameters": [{"name": "staterror_Signal-Region", "fixed": True, "inits": [1.1]}],
                "poi": "Signal strength",
            },
            "name": "fit",
        }
    ],
    "observations": [{"data": [475], "name": "Signal Region"}],
    "version": "1.0.0",
}
ws = pyhf.Workspace(spec)
model = ws.model()
print(model.config.suggested_init())  # -> [1.1, 1.0]
spec["measurements"][0]["config"]["parameters"][0]["inits"] = [1.2]
print(model.config.suggested_init())  # -> [1.1, 1.0]
model = ws.model()
print(model.config.suggested_init())  # -> [1.2, 1.0]
```

Models on the other hand are immutable. What do you think about making workspaces immutable as well? While I do not have a concrete example in mind, I imagine it could prevent some difficult-to-debug scenarios where the workspace changes without the user noticing.

# Relevant Issues and Pull Requests

none


Discussion:
some possible motivation for this change from @kratsg:

> if you change the spec, summary information is not recomputed.
this is because  we deep-copy the spec in Model:

https://github.com/scikit-hep/pyhf/blob/master/src/pyhf/pdf.py#L559

while we reuse the ctor-argument in `Workspace.__init__.py`

https://github.com/scikit-hep/pyhf/blob/master/src/pyhf/workspace.py#L286

I would personally introduce a deepcopy in wspace as well.

From an IRIS-HEP Slack comment form @kratsg:

> not too much work. we could disentangle the `class Workspace(dict)` part, and then hide the `spec` inside a member variable inside that object. then provide a custom `to_json` method since we do rely on converting `Workspace` back to JSON (which I lazily implemented by just inheriting from `dict`).
I thiink a simple change like

```
spec_copy = copy.deepcopy(spec)
super(Workspace, self).__init__(spec_copy, channels=spec_copy['channels'])
```

should suffice
@lukasheinrich Sorry hadn't taken the time to read all the other replies when I copy pasted that in. That would be great.

Resolving patch:
diff --git a/src/pyhf/workspace.py b/src/pyhf/workspace.py
--- a/src/pyhf/workspace.py
+++ b/src/pyhf/workspace.py
@@ -283,6 +283,7 @@ class Workspace(_ChannelSummaryMixin, dict):
 
     def __init__(self, spec, **config_kwargs):
         """Workspaces hold the model, data and measurements."""
+        spec = copy.deepcopy(spec)
         super(Workspace, self).__init__(spec, channels=spec['channels'])
         self.schema = config_kwargs.pop('schema', 'workspace.json')
         self.version = config_kwargs.pop('version', spec.get('version', None))


Test patch:
diff --git a/tests/test_workspace.py b/tests/test_workspace.py
--- a/tests/test_workspace.py
+++ b/tests/test_workspace.py
@@ -844,3 +844,23 @@ def test_closure_over_workspace_build():
     newworkspace = pyhf.Workspace.build(newmodel, newdata)
 
     assert pyhf.utils.digest(newworkspace) == pyhf.utils.digest(workspace)
+
+
+def test_wspace_immutable():
+    model = pyhf.simplemodels.hepdata_like(
+        signal_data=[12.0, 11.0], bkg_data=[50.0, 52.0], bkg_uncerts=[3.0, 7.0]
+    )
+    data = [51, 48]
+    workspace = pyhf.Workspace.build(model, data)
+
+    spec = json.loads(json.dumps(workspace))
+
+    ws = pyhf.Workspace(spec)
+    model = ws.model()
+    before = model.config.suggested_init()
+    spec["measurements"][0]["config"]["parameters"][0]["inits"] = [1.5]
+
+    model = ws.model()
+    after = model.config.suggested_init()
+
+    assert before == after