This is an automated email from the ASF dual-hosted git repository.

Gerrrr pushed a commit to branch custom-datasets
in repository https://gitbox.apache.org/repos/asf/otava-playground.git

commit cea07c35f36b631705417e8250f93316f537a81a
Author: Alex Sorokoumov <[email protected]>
AuthorDate: Sat May 30 17:31:40 2026 -0700

    Add Dataset mode for comparing algorithms on real series
    
    Adds a third top-level mode to the web visualizer ("Dataset", alongside
    "Single Pattern" and "Mix Patterns") for loading real performance data and
    seeing where each Otava algorithm variant places change points on the same
    series. The TigerBeetle dataset bundled in apache/otava's perf/perf_test.py
    ships as the default preset; a "Custom (paste below)" option accepts
    JSON-array or whitespace/comma-separated numbers.
    
    The Otava analysis panel now offers a checkbox per algorithm variant.
    In addition to split-edivisive and orig-edivisive, I have also added
    deterministic-edivisive to facilitate review of
    https://github.com/apache/otava/pull/154.
    
    Backend additions:
    - otava_test_data.datasets package + TIGERBEETLE preset.
    - GET /api/datasets — bundled-preset metadata.
    - GET /api/datasets/{name} — one preset's series + metadata.
    - GET /api/algorithms — which algorithm functions are available.
    - POST /api/compare — run multiple algorithms on a series, return all 
results.
    - /api/generate and /api/analyze accept 
otava_algorithm=split|orig|deterministic
      to pick which variant runs in single/mix mode.
    
    Algorithm names are validated as Literal["split","orig","deterministic"], so
    unknown values fail with 422 at the request boundary instead of returning
    200 with an embedded error string.
    
    Dataset mode reuses the existing chart container with a single chart that
    overlays each enabled algorithm's change points as colour-coded vertical
    lines. The new "Show All Graphs" / "Analyse" buttons and the ground-truth
    sections (accuracy table, comparison tables, chart legend) are hidden in
    Dataset mode. Compare requests are guarded by an AbortController so a slow
    earlier request can't race a newer one. The results table is built with
    DOM nodes (not string concatenation) so server-supplied strings can't inject
    HTML. Custom-paste input filters out non-numeric tokens and tells the user
    how many were dropped.
    
    Also fixes an existing latent bug: Starlette 1.x changed TemplateResponse's
    positional argument order. The pre-existing `/` route was using the old
    signature, which crashed with "unhashable type: dict" once Jinja2's cache
    saw the context dict as the template name. Both routes updated.
---
 README.md                                     |  10 +
 docs/index.md                                 |   7 +
 docs/visualizer.md                            |  69 ++++
 pyproject.toml                                |   1 +
 src/otava_test_data/datasets/__init__.py      |  37 ++
 src/otava_test_data/datasets/tigerbeetle.py   |  75 ++++
 src/otava_test_data/tests/test_compare_api.py | 161 +++++++++
 src/otava_test_data/tests/test_datasets.py    |  53 +++
 src/otava_test_data/web/main.py               | 201 +++++++++--
 src/otava_test_data/web/static/css/style.css  | 172 +++++++++
 src/otava_test_data/web/static/js/app.js      | 500 +++++++++++++++++++++++---
 src/otava_test_data/web/templates/index.html  |  90 ++++-
 12 files changed, 1287 insertions(+), 89 deletions(-)

diff --git a/README.md b/README.md
index afea536..5ab2449 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,16 @@ inv web-start
 
 Then open http://127.0.0.1:8100 in your browser.
 
+### Compare algorithms on a real dataset
+
+A third mode, **Dataset**, lets you load a bundled or pasted time series and
+see which change points each Otava algorithm variant
+(`compute_change_points`, `compute_change_points_orig`,
+`compute_change_points_deterministic`) detects on the same data. The 
TigerBeetle
+benchmark dataset ships as the default preset. The Otava analysis panel now
+exposes the same algorithm checkboxes in all three modes. See
+`docs/visualizer.md` for details.
+
 ## Installation
 
 ```bash
diff --git a/docs/index.md b/docs/index.md
index 55df55c..98f1799 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -105,6 +105,13 @@ otava-gen list
 otava-gen info step_function
 ```
 
+## Compare algorithms on a real dataset
+
+A third mode, **Dataset**, lets you load a bundled or pasted time series and
+see which change points each Otava algorithm variant detects on the same data.
+The TigerBeetle benchmark dataset ships as the default preset. See the
+[Web Visualizer](visualizer.md) doc for details.
+
 ## Contents
 
 ```{toctree}
diff --git a/docs/visualizer.md b/docs/visualizer.md
index 617f668..bae2143 100644
--- a/docs/visualizer.md
+++ b/docs/visualizer.md
@@ -355,3 +355,72 @@ The visualizer calculates these metrics when comparing 
Otava's results to ground
 ## Show All Graphs
 
 Click "Show All Graphs" to run analysis on all generators simultaneously. This 
produces a grid of charts showing how well the analysis methods perform across 
different types of change point patterns, allowing you to quickly compare 
detection accuracy across pattern types.
+
+## Dataset Mode — compare algorithms on a real series
+
+The mode toggle in the top section has three options: **Single Pattern**, **Mix
+Patterns**, and **Dataset**. Dataset mode replaces the synthetic generator grid
+with a dataset picker and a "Custom (paste below)" textarea so you can load a
+real time series and see where each Otava algorithm variant places change
+points on it.
+
+Ground-truth-only UI (accuracy metrics, comparison tables, chart legend) is
+hidden in Dataset mode because real data doesn't come with known change points.
+
+### Bundled datasets
+
+| Name | Length | Description |
+|------|--------|-------------|
+| `tigerbeetle` | 365 | Same series used by `apache/otava`'s 
`perf/perf_test.py`. Has a couple of distinctive ups and downs, an anomalous 
drop, then an upward slope, then normal variance. |
+
+Add more presets by dropping a file in `src/otava_test_data/datasets/` and
+registering it in `datasets/__init__.py::DATASETS`.
+
+### Algorithm checkboxes
+
+The Otava Analysis panel exposes a checkbox per algorithm variant in all three
+modes:
+
+| Key | Otava function | CLI flag |
+|-----|----------------|----------|
+| `split` | `compute_change_points` | (default) |
+| `orig` | `compute_change_points_orig` | `--orig-edivisive` |
+| `deterministic` | `compute_change_points_deterministic` | 
`--deterministic-edivisive` ([PR](https://github.com/apache/otava/pull/154)) |
+
+If `compute_change_points_deterministic` isn't importable in the installed
+otava version, the checkbox is disabled with a "(not in installed otava)"
+indicator next to it.
+
+### Custom data
+
+Pick "Custom (paste below)" from the source dropdown and paste a series as
+either a JSON array (`[1.2, 3.4, ...]`) or whitespace/comma-separated numbers.
+Non-numeric tokens are filtered out and the UI tells you how many were dropped.
+
+### HTTP endpoints
+
+- `GET /api/datasets` — bundled-preset metadata.
+- `GET /api/datasets/{name}` — one preset's series + metadata.
+- `GET /api/algorithms` — change-point algorithms exposed by the installed
+  otava version.
+- `POST /api/compare?window_len=...&max_pvalue=...&min_magnitude=...` —
+  run selected algorithms on a series. Body:
+  ```json
+  {"data": [1.2, 3.4, ...], "algorithms": ["split", "orig"]}
+  ```
+  Response:
+  ```json
+  {
+    "results": {
+      "split": {"indices": [15, 71, ...], "count": 8},
+      "orig":  {"indices": [15, 71, ...], "count": 5}
+    },
+    "parameters": {"window_len": 50, "max_pvalue": 0.001, "min_magnitude": 0.0}
+  }
+  ```
+  If `algorithms` is omitted, every available algorithm runs.
+
+The synthetic-pattern endpoints `/api/generate/{name}` and
+`/api/analyze/{name}` also accept an `otava_algorithm` query parameter
+(`split` | `orig` | `deterministic`) so synthetic-pattern flows can pick an
+algorithm variant too.
diff --git a/pyproject.toml b/pyproject.toml
index 537c8d7..80fe338 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,7 @@ dev = [
     "pytest-cov>=4.0.0",
     "invoke>=2.0.0",
     "ruff>=0.1.0",
+    "httpx>=0.27.0",  # required by fastapi.testclient
 ]
 docs = [
     "sphinx>=7.0.0",
diff --git a/src/otava_test_data/datasets/__init__.py 
b/src/otava_test_data/datasets/__init__.py
new file mode 100644
index 0000000..51c6009
--- /dev/null
+++ b/src/otava_test_data/datasets/__init__.py
@@ -0,0 +1,37 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Real-world performance datasets bundled for algorithm comparison."""
+
+from otava_test_data.datasets.tigerbeetle import TIGERBEETLE
+
+DATASETS = {
+    TIGERBEETLE["name"]: TIGERBEETLE,
+}
+
+
+def list_datasets() -> list[dict]:
+    """Return metadata for every bundled dataset (without the series 
itself)."""
+    return [
+        {"name": ds["name"], "title": ds["title"], "description": 
ds["description"]}
+        for ds in DATASETS.values()
+    ]
+
+
+def get_dataset(name: str) -> dict | None:
+    """Return the full dataset entry (series + metadata) or None if unknown."""
+    return DATASETS.get(name)
diff --git a/src/otava_test_data/datasets/tigerbeetle.py 
b/src/otava_test_data/datasets/tigerbeetle.py
new file mode 100644
index 0000000..e32970f
--- /dev/null
+++ b/src/otava_test_data/datasets/tigerbeetle.py
@@ -0,0 +1,75 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""
+TigerBeetle benchmark series — same values used by apache/otava's
+perf/perf_test.py for algorithm performance benchmarking.
+
+Source: https://github.com/apache/otava/blob/master/perf/perf_test.py
+"""
+
+TIGERBEETLE_SERIES = [
+    26705, 26475, 26641, 26806, 26835, 26911, 26564, 26812, 26874, 26682,
+    15672, 26745, 26460, 26977, 26851, 23412, 23547, 23674, 23519, 23670,
+    23662, 23462, 23750, 23717, 23524, 23588, 23687, 23793, 23937, 23715,
+    23570, 23730, 23690, 23699, 23670, 23860, 23988, 23652, 23681, 23798,
+    23728, 23604, 23523, 23412, 23685, 23773, 23771, 23718, 23409, 23739,
+    23674, 23597, 23682, 23680, 23711, 23660, 23990, 23938, 23742, 23703,
+    23536, 24363, 24414, 24483, 24509, 24944, 24235, 24560, 24236, 24667,
+    24730, 28346, 28437, 28436, 28057, 28217, 28456, 28427, 28398, 28250,
+    28331, 28222, 28726, 28578, 28345, 28274, 28514, 28590, 28449, 28305,
+    28411, 28788, 28404, 28821, 28580, 27483, 26805, 27487, 27124, 26898,
+    27295, 26951, 27312, 27660, 27154, 27050, 26989, 27193, 27503, 27326,
+    27375, 27513, 27057, 27421, 27574, 27609, 27123, 27824, 27644, 27394,
+    27836, 27949, 27702, 27457, 27272, 28207, 27802, 27516, 27586, 28005,
+    27768, 28543, 28237, 27915, 28437, 28342, 27733, 28296, 28524, 28687,
+    28258, 28611, 29360, 28590, 29641, 28965, 29474, 29256, 28611, 28205,
+    28539, 27962, 28398, 28509, 28240, 28592, 28102, 28461, 28578, 28669,
+    28507, 28535, 28226, 28536, 28561, 28087, 27953, 28398, 28007, 28518,
+    28337, 28242, 28607, 28545, 28514, 28377, 28010, 28412, 28633, 28576,
+    28195, 28637, 28724, 28466, 28287, 28719, 28425, 28860, 28842, 28604,
+    28327, 28216, 28946, 28918, 29287, 28725, 29148, 29541, 29137, 29628,
+    29087, 28612, 29154, 29108, 28884, 29234, 28695, 28969, 28809, 28695,
+    28634, 28916, 29852, 29389, 29757, 29531, 29363, 29251, 29552, 29561,
+    29046, 29795, 29022, 29395, 28921, 29739, 29257, 29455, 29376, 29528,
+    28909, 29492, 28984, 29621, 29026, 29457, 29102, 29114, 28924, 29162,
+    29259, 29554, 29616, 29211, 29367, 29460, 28836, 29645, 29586, 28848,
+    29324, 28969, 29150, 29243, 29081, 29312, 28923, 29272, 29117, 29072,
+    29529, 29737, 29652, 29612, 29856, 29012, 30402, 29969, 29309, 29439,
+    29285, 29421, 29023, 28772, 29692, 29416, 29267, 29542, 29904, 30045,
+    29739, 29945, 29141, 29163, 29765, 29197, 29441, 28910, 29504, 29614,
+    29643, 29506, 29420, 29672, 29432, 29784, 29888, 29309, 29247, 29816,
+    29254, 29813, 29451, 29382, 29618, 28558, 29845, 29499, 29283, 29184,
+    29246, 28790, 29952, 29145, 29415, 30437, 29227, 29605, 29859, 29156,
+    29807, 29406, 29734, 29861, 29140, 29983, 29832, 29919, 29896, 29991,
+    29266, 29001, 29459, 29548, 29310, 29042, 29303, 29894, 29091, 29018,
+    29537, 29614, 29180, 29736, 29500, 29218, 29581, 28906, 28542, 29306,
+    28987, 29878, 28865, 30272, 29707, 29662, 29815, 30492, 29347, 30096,
+    29054, 30238, 28813, 31895, 28915,
+]
+
+TIGERBEETLE = {
+    "name": "tigerbeetle",
+    "title": "TigerBeetle",
+    "description": (
+        "TigerBeetle dataset used for demo purposes at Nyrkiö and as the "
+        "performance benchmark series in apache/otava's perf_test.py. "
+        "Contains a couple of distinctive ups and downs, an anomalous drop, "
+        "then an upward slope, with the rest as normal variance."
+    ),
+    "series": TIGERBEETLE_SERIES,
+}
diff --git a/src/otava_test_data/tests/test_compare_api.py 
b/src/otava_test_data/tests/test_compare_api.py
new file mode 100644
index 0000000..f818118
--- /dev/null
+++ b/src/otava_test_data/tests/test_compare_api.py
@@ -0,0 +1,161 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for the /api/datasets and /api/compare endpoints."""
+
+from fastapi.testclient import TestClient
+
+from otava_test_data.web.main import app
+
+client = TestClient(app)
+
+
+def test_list_datasets():
+    r = client.get("/api/datasets")
+    assert r.status_code == 200
+    body = r.json()
+    assert "datasets" in body
+    names = [d["name"] for d in body["datasets"]]
+    assert "tigerbeetle" in names
+
+
+def test_get_tigerbeetle():
+    r = client.get("/api/datasets/tigerbeetle")
+    assert r.status_code == 200
+    body = r.json()
+    assert body["name"] == "tigerbeetle"
+    assert len(body["series"]) == 365
+
+
+def test_get_unknown_dataset_404():
+    r = client.get("/api/datasets/nope")
+    assert r.status_code == 404
+
+
+def test_list_algorithms_includes_split():
+    r = client.get("/api/algorithms")
+    assert r.status_code == 200
+    body = r.json()
+    assert "split" in body["algorithms"]
+    # split must be available because apache-otava is a hard dependency.
+    assert body["algorithms"]["split"]["available"] is True
+
+
+def test_compare_runs_split_on_tigerbeetle():
+    series = client.get("/api/datasets/tigerbeetle").json()["series"]
+    r = client.post(
+        "/api/compare?max_pvalue=0.001",
+        json={"data": series, "algorithms": ["split"]},
+    )
+    assert r.status_code == 200
+    body = r.json()
+    assert "split" in body["results"]
+    assert "indices" in body["results"]["split"]
+    # TigerBeetle has clear change points — split should detect at least one.
+    assert body["results"]["split"]["indices"]
+
+
+def test_compare_rejects_empty_data():
+    r = client.post("/api/compare", json={"data": [], "algorithms": ["split"]})
+    assert r.status_code == 400
+
+
+def test_compare_rejects_unknown_algorithm():
+    """Unknown algorithm names should fail validation, not return 200 with an 
inline error."""
+    r = client.post(
+        "/api/compare",
+        json={"data": [1.0, 2.0, 1.0, 2.0, 1.0, 2.0], "algorithms": 
["nonexistent"]},
+    )
+    assert r.status_code == 422
+
+
+def test_compare_default_runs_all_available_algorithms():
+    """When `algorithms` is omitted, every available algorithm in ALGORITHMS 
runs."""
+    available = {
+        name for name, info in 
client.get("/api/algorithms").json()["algorithms"].items()
+        if info["available"]
+    }
+    r = client.post(
+        "/api/compare",
+        json={"data": [1.0, 1.1, 1.0, 1.2, 5.0, 5.1, 5.0, 5.2, 5.3, 5.1]},
+    )
+    assert r.status_code == 200
+    assert set(r.json()["results"].keys()) == available
+
+
+def test_compare_accepts_floats_and_round_trips_through_json():
+    """Float series shouldn't blow up at JSON serialization."""
+    series = [1.5, 2.5, 1.7, 2.3, 1.4, 3.9, 4.1, 4.0, 4.2, 4.3, 4.1]
+    r = client.post("/api/compare", json={"data": series, "algorithms": 
["split"]})
+    assert r.status_code == 200
+    body = r.json()
+    # Indices must be JSON-serializable plain ints, not numpy types.
+    for idx in body["results"]["split"]["indices"]:
+        assert isinstance(idx, int)
+
+
+def test_index_exposes_dataset_mode():
+    """The main page should expose the dataset-mode button and section."""
+    r = client.get("/")
+    assert r.status_code == 200
+    assert 'id="mode-dataset-btn"' in r.text
+    assert 'id="dataset-section"' in r.text
+    assert 'otava-algo-checkbox' in r.text
+
+
+def test_generate_with_otava_algorithm_orig():
+    """The /api/generate endpoint should accept otava_algorithm=orig."""
+    r = client.get(
+        "/api/generate/step_function",
+        params={
+            "length": 100, "seed": 42,
+            "run_otava": True, "otava_algorithm": "orig",
+            "max_pvalue": 0.001,
+        },
+    )
+    assert r.status_code == 200
+    body = r.json()
+    assert "otava" in body
+    # No error path expected here.
+    assert "error" not in body["otava"]
+    assert "parameters" in body["otava"], body["otava"]
+    assert body["otava"]["parameters"]["algorithm"] == "orig"
+
+
+def test_generate_rejects_unknown_otava_algorithm():
+    r = client.get(
+        "/api/generate/step_function",
+        params={"length": 50, "seed": 42, "otava_algorithm": "nonexistent"},
+    )
+    assert r.status_code == 422
+
+
+def test_analyze_accepts_otava_algorithm():
+    """`/api/analyze` documents the otava_algorithm param — verify it actually 
works."""
+    r = client.get(
+        "/api/analyze/step_function",
+        params={
+            "length": 100, "seed": 42,
+            "otava_algorithm": "orig",
+            "max_pvalue": 0.001,
+        },
+    )
+    assert r.status_code == 200
+    body = r.json()
+    assert "otava" in body
+    assert "error" not in body["otava"]
+    assert body["otava"]["parameters"]["algorithm"] == "orig"
diff --git a/src/otava_test_data/tests/test_datasets.py 
b/src/otava_test_data/tests/test_datasets.py
new file mode 100644
index 0000000..956bbe0
--- /dev/null
+++ b/src/otava_test_data/tests/test_datasets.py
@@ -0,0 +1,53 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Tests for the bundled real-world datasets."""
+
+
+from otava_test_data.datasets import DATASETS, get_dataset, list_datasets
+
+
+def test_list_includes_tigerbeetle():
+    names = [d["name"] for d in list_datasets()]
+    assert "tigerbeetle" in names
+
+
+def test_get_unknown_returns_none():
+    assert get_dataset("not-a-dataset") is None
+
+
+def test_tigerbeetle_metadata_and_shape():
+    ds = get_dataset("tigerbeetle")
+    assert ds is not None
+    assert ds["name"] == "tigerbeetle"
+    assert ds["title"]
+    assert ds["description"]
+    assert isinstance(ds["series"], list)
+    # Same length as the upstream apache/otava perf_test.py series.
+    assert len(ds["series"]) == 365
+    assert all(isinstance(v, (int, float)) for v in ds["series"])
+
+
+def test_list_omits_series_payload():
+    # list_datasets() should not echo the full series — only metadata.
+    for d in list_datasets():
+        assert "series" not in d
+
+
+def test_registry_keyed_by_name():
+    for name, ds in DATASETS.items():
+        assert ds["name"] == name
diff --git a/src/otava_test_data/web/main.py b/src/otava_test_data/web/main.py
index 9771d2b..a223437 100644
--- a/src/otava_test_data/web/main.py
+++ b/src/otava_test_data/web/main.py
@@ -7,7 +7,7 @@ Or: otava-web
 
 import json
 from pathlib import Path
-from typing import Any
+from typing import Any, Literal
 
 import numpy as np
 from fastapi import FastAPI, Request, Query, Body
@@ -16,6 +16,10 @@ from pydantic import BaseModel
 from fastapi.staticfiles import StaticFiles
 from fastapi.templating import Jinja2Templates
 
+
+# Algorithm name aliases — Literal type used both by query params and request 
bodies.
+AlgorithmName = Literal["split", "orig", "deterministic"]
+
 # Otava imports - optional dependency
 try:
     from otava.analysis import compute_change_points
@@ -24,6 +28,21 @@ except ImportError:
     OTAVA_AVAILABLE = False
     compute_change_points = None
 
+# Optional alternative algorithms — present on newer otava versions /
+# https://github.com/apache/otava/pull/154.
+# Feature-detected at import time so the /compare UI only offers what works.
+try:
+    from otava.analysis import compute_change_points_orig
+except ImportError:
+    compute_change_points_orig = None
+
+try:
+    from otava.analysis import compute_change_points_deterministic
+except ImportError:
+    compute_change_points_deterministic = None
+
+from otava_test_data.datasets import DATASETS, get_dataset, list_datasets
+
 from otava_test_data.generators.basic import (
     constant,
     noise_normal,
@@ -1190,15 +1209,17 @@ def run_otava_analysis(
     window_len: int = 30,
     max_pvalue: float = 0.05,
     min_magnitude: float = 0.0,
+    algorithm: str = "split",
 ) -> dict[str, Any]:
     """
-    Run Otava change point detection on data.
+    Run an Otava change point detection algorithm on data.
 
     Args:
-        data: Time series data as numpy array.
-        window_len: Minimum window length for detection.
+        data: Time series data.
+        window_len: Window length (only meaningful for `split`).
         max_pvalue: Maximum p-value threshold for significance.
         min_magnitude: Minimum magnitude of change to report.
+        algorithm: Which algorithm to run — 'split' (default), 'orig', or 
'deterministic'.
 
     Returns:
         Dictionary with detected change points and metrics.
@@ -1211,36 +1232,60 @@ def run_otava_analysis(
             "count": 0,
         }
 
-    try:
-        result = compute_change_points(
-            data,
-            window_len=window_len,
-            max_pvalue=max_pvalue,
-            min_magnitude=min_magnitude,
-        )
+    if algorithm == "split":
+        fn, kwargs = compute_change_points, {
+            "window_len": window_len, "max_pvalue": max_pvalue,
+            "min_magnitude": min_magnitude,
+        }
+    elif algorithm == "orig":
+        fn, kwargs = compute_change_points_orig, {"max_pvalue": max_pvalue}
+    elif algorithm == "deterministic":
+        fn, kwargs = compute_change_points_deterministic, {
+            "max_pvalue": max_pvalue, "min_magnitude": min_magnitude,
+        }
+    else:
+        return {
+            "error": f"unknown algorithm: {algorithm}",
+            "detected_change_points": [], "detected_indices": [], "count": 0,
+        }
 
-        # Result is a tuple, first element is list of ChangePoint objects
+    if fn is None:
+        return {
+            "error": f"{algorithm} not available in installed otava version",
+            "detected_change_points": [], "detected_indices": [], "count": 0,
+        }
+
+    try:
+        # otava.analysis.compute_change_points crashes with an unmessaged 
ValueError
+        # on numpy arrays for some window/data combinations; a plain list of
+        # Python floats sidesteps it. Match the coercion done in /api/compare.
+        series = [float(v) for v in data]
+        result = fn(series, **kwargs)
         change_points_list = result[0] if isinstance(result, tuple) else result
 
         detected = []
         for cp in change_points_list:
-            detected.append({
-                "index": int(cp.index),  # Convert numpy.int64 to int
-                "mean_before": float(cp.stats.mean_1),
-                "mean_after": float(cp.stats.mean_2),
-                "std_before": float(cp.stats.std_1),
-                "std_after": float(cp.stats.std_2),
-                "pvalue": float(cp.stats.pvalue),
-            })
+            entry = {"index": int(cp.index)}
+            stats = getattr(cp, "stats", None)
+            if stats is not None:
+                for key in ("mean_1", "mean_2", "std_1", "std_2", "pvalue"):
+                    val = getattr(stats, key, None)
+                    if val is not None:
+                        out_key = {
+                            "mean_1": "mean_before", "mean_2": "mean_after",
+                            "std_1": "std_before",   "std_2": "std_after",
+                            "pvalue": "pvalue",
+                        }[key]
+                        entry[out_key] = float(val)
+            detected.append(entry)
 
         return {
             "detected_change_points": detected,
             "detected_indices": [cp["index"] for cp in detected],
             "count": len(detected),
             "parameters": {
-                "window_len": window_len,
-                "max_pvalue": max_pvalue,
-                "min_magnitude": min_magnitude,
+                "window_len": window_len, "max_pvalue": max_pvalue,
+                "min_magnitude": min_magnitude, "algorithm": algorithm,
             },
         }
 
@@ -1392,6 +1437,7 @@ def timeseries_to_dict(
             window_len=params.get("window_len", 30),
             max_pvalue=params.get("max_pvalue", 0.05),
             min_magnitude=params.get("min_magnitude", 0.0),
+            algorithm=params.get("algorithm", "split"),
         )
         result["otava"] = otava_result
 
@@ -1413,9 +1459,9 @@ def timeseries_to_dict(
 async def index(request: Request):
     """Main page with generator visualization."""
     return templates.TemplateResponse(
+        request,
         "index.html",
         {
-            "request": request,
             "generators": GENERATORS,
             "default_length": 200,
             "version": __version__,
@@ -1457,6 +1503,7 @@ async def generate_data(
     length: int = Query(default=200, ge=10, le=2000),
     seed: int = Query(default=42),
     run_otava: bool = Query(default=False, description="Run Otava analysis"),
+    otava_algorithm: AlgorithmName = Query(default="split", description="Otava 
algorithm to run"),  # noqa: B008
     window_len: int = Query(default=99999, ge=5, le=100000, description="Otava 
window length"),
     max_pvalue: float = Query(default=0.01, ge=0.0, le=1.0, description="Otava 
max p-value"),
     tolerance: int = Query(default=5, ge=0, le=50, description="Accuracy 
tolerance"),
@@ -1496,6 +1543,7 @@ async def generate_data(
             "window_len": window_len,
             "max_pvalue": max_pvalue,
             "tolerance": tolerance,
+            "algorithm": otava_algorithm,
         }
         return timeseries_to_dict(ts, include_otava=run_otava, 
otava_params=otava_params)
     except Exception as e:
@@ -1510,6 +1558,7 @@ async def analyze_with_otava(
     generator_name: str,
     length: int = Query(default=200, ge=10, le=2000),
     seed: int = Query(default=42),
+    otava_algorithm: AlgorithmName = Query(default="split", description="Otava 
algorithm to run"),  # noqa: B008
     window_len: int = Query(default=30, ge=5, le=100, description="Otava 
window length"),
     max_pvalue: float = Query(default=0.00001, ge=0.0, le=1.0, 
description="Otava max p-value"),
     min_magnitude: float = Query(default=0.0, ge=0, description="Minimum 
change magnitude"),
@@ -1550,6 +1599,7 @@ async def analyze_with_otava(
             "max_pvalue": max_pvalue,
             "min_magnitude": min_magnitude,
             "tolerance": tolerance,
+            "algorithm": otava_algorithm,
         }
         return timeseries_to_dict(ts, include_otava=True, 
otava_params=otava_params)
     except Exception as e:
@@ -1643,6 +1693,109 @@ async def detect_change_points(
         )
 
 
+# ----- Dataset comparison: presets and multi-algorithm detection -----
+
+ALGORITHMS = {
+    "split": {
+        "title": "split-edivisive (default)",
+        "description": (
+            "Hunter-style split-merge e-divisive + Welch t-test significance. "
+            "Otava default (compute_change_points)."
+        ),
+        "available": compute_change_points is not None,
+    },
+    "orig": {
+        "title": "orig-edivisive",
+        "description": (
+            "Original e-divisive with permutation significance test "
+            "(compute_change_points_orig, --orig-edivisive)."
+        ),
+        "available": compute_change_points_orig is not None,
+    },
+    "deterministic": {
+        "title": "deterministic-edivisive",
+        "description": (
+            "Original e-divisive with deterministic Welch t-test significance "
+            "(compute_change_points_deterministic, --deterministic-edivisive, "
+            "https://github.com/apache/otava/pull/154)."
+        ),
+        "available": compute_change_points_deterministic is not None,
+    },
+}
+
+
+def _run_algorithm(name: str, data, window_len: int, max_pvalue: float,
+                   min_magnitude: float) -> dict[str, Any]:
+    """Adapter from /api/compare's response shape to run_otava_analysis."""
+    res = run_otava_analysis(
+        data,
+        window_len=window_len,
+        max_pvalue=max_pvalue,
+        min_magnitude=min_magnitude,
+        algorithm=name,
+    )
+    out = {"indices": res.get("detected_indices", []), "count": 
res.get("count", 0)}
+    if res.get("error"):
+        out["error"] = res["error"]
+    return out
+
+
+class CompareRequest(BaseModel):
+    """Request body for /api/compare."""
+    data: list[float]
+    algorithms: list[AlgorithmName] | None = None  # default: all available
+
+
[email protected]("/api/datasets")
+async def get_datasets():
+    """List bundled real-world datasets."""
+    return {"datasets": list_datasets()}
+
+
[email protected]("/api/datasets/{name}")
+async def get_dataset_endpoint(name: str):
+    """Return one bundled dataset's series + metadata."""
+    ds = get_dataset(name)
+    if ds is None:
+        return JSONResponse(status_code=404, content={"error": f"unknown 
dataset: {name}"})
+    return ds
+
+
[email protected]("/api/algorithms")
+async def list_algorithms():
+    """List change-point algorithms exposed by the installed otava package."""
+    return {"algorithms": ALGORITHMS}
+
+
[email protected]("/api/compare")
+async def compare_algorithms(
+    request: CompareRequest,
+    window_len: int = Query(default=50, ge=5, le=100000),
+    max_pvalue: float = Query(default=0.001, ge=0.0, le=1.0),
+    min_magnitude: float = Query(default=0.0, ge=0),
+):
+    """Run multiple change-point algorithms on the same series and return all 
results."""
+    if not request.data:
+        return JSONResponse(status_code=400, content={"error": "No data 
provided"})
+    if not OTAVA_AVAILABLE:
+        return JSONResponse(status_code=503, content={"error": "apache-otava 
not installed"})
+
+    algorithms = request.algorithms or [n for n, a in ALGORITHMS.items() if 
a["available"]]
+    # run_otava_analysis handles float coercion internally.
+    results = {
+        name: _run_algorithm(name, request.data, window_len, max_pvalue, 
min_magnitude)
+        for name in algorithms
+    }
+    return {
+        "results": results,
+        "parameters": {
+            "window_len": window_len,
+            "max_pvalue": max_pvalue,
+            "min_magnitude": min_magnitude,
+        },
+    }
+
+
 def run():
     """Run the web server."""
     import uvicorn
diff --git a/src/otava_test_data/web/static/css/style.css 
b/src/otava_test_data/web/static/css/style.css
index 01cf3dd..dc9c65b 100644
--- a/src/otava_test_data/web/static/css/style.css
+++ b/src/otava_test_data/web/static/css/style.css
@@ -1843,3 +1843,175 @@ footer a:hover {
         grid-template-columns: 1fr;
     }
 }
+
+/* ============================================================
+   Dataset mode — load a bundled or pasted series and compare
+   Otava algorithm variants on the same data.
+   ============================================================ */
+
+.dataset-section {
+    background: #fafafa;
+    border: 1px solid #e2e8f0;
+    border-radius: 8px;
+    padding: 16px 20px;
+    margin: 0 0 20px;
+}
+
+.dataset-section.hidden {
+    display: none;
+}
+
+/* The mode toggle bar sits above the per-mode content. */
+.mode-toggle-section {
+    margin-bottom: 8px;
+}
+
+.dataset-row {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    margin-bottom: 16px;
+}
+
+.dataset-label {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    font-size: 14px;
+    font-weight: 600;
+    max-width: 480px;
+}
+
+.dataset-label select {
+    padding: 6px 8px;
+    font: inherit;
+    border: 1px solid #cbd5e1;
+    border-radius: 4px;
+    background: #fff;
+}
+
+.dataset-description {
+    font-size: 13px;
+    color: #64748b;
+    margin: 0;
+    min-height: 1.4em;
+}
+
+.dataset-custom {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    font-size: 14px;
+    font-weight: 600;
+}
+
+.dataset-custom.hidden {
+    display: none;
+}
+
+.dataset-custom textarea {
+    font-family: ui-monospace, "SF Mono", Monaco, monospace;
+    font-size: 13px;
+    padding: 8px;
+    border: 1px solid #cbd5e1;
+    border-radius: 4px;
+    resize: vertical;
+}
+
+.dataset-hint {
+    margin: 8px 0 0;
+    font-size: 13px;
+    color: #475569;
+}
+
+.dataset-status {
+    margin: 8px 0 0;
+    padding: 6px 10px;
+    background: #eef2ff;
+    border: 1px solid #c7d2fe;
+    border-radius: 4px;
+    font-size: 13px;
+    color: #1e1b4b;
+}
+
+.dataset-status--error {
+    background: #fef2f2;
+    border-color: #fecaca;
+    color: #7f1d1d;
+}
+
+.otava-algo-row .algo-unavail {
+    margin-left: 4px;
+    color: #94a3b8;
+    font-style: italic;
+}
+
+.dataset-results.hidden {
+    display: none;
+}
+
+.dataset-results h3 {
+    margin: 16px 0 8px;
+}
+
+.dataset-results-table {
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 14px;
+}
+
+.dataset-results-table th,
+.dataset-results-table td {
+    border: 1px solid #e2e8f0;
+    padding: 6px 10px;
+    text-align: left;
+    vertical-align: top;
+}
+
+.dataset-results-table th {
+    background: #f1f5f9;
+}
+
+.dataset-results-table .swatch {
+    display: inline-block;
+    width: 12px;
+    height: 12px;
+    border-radius: 2px;
+    margin-right: 8px;
+    vertical-align: middle;
+}
+
+/* In dataset mode, hide synthetic-data and ground-truth UI. */
+body[data-mode="dataset"] .hide-in-dataset {
+    display: none !important;
+}
+
+/* Otava algorithm checkbox group (used in the Otava analysis panel). */
+.otava-algos {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    margin: 4px 0 12px;
+    font-size: 13px;
+}
+
+.otava-algo-row {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    white-space: nowrap;
+    line-height: 1.4;
+}
+
+.otava-algo-row input[type="checkbox"] {
+    flex-shrink: 0;
+}
+
+.otava-algo-row input[disabled] {
+    cursor: not-allowed;
+}
+
+.algo-hint {
+    color: #64748b;
+    font-size: 11px;
+}
diff --git a/src/otava_test_data/web/static/js/app.js 
b/src/otava_test_data/web/static/js/app.js
index 8054e4f..134b241 100644
--- a/src/otava_test_data/web/static/js/app.js
+++ b/src/otava_test_data/web/static/js/app.js
@@ -12,14 +12,33 @@ let generatorTileCharts = {};  // Mini charts for generator 
tiles
 let analysisMethods = {};  // Tutorial content for analysis methods
 let tutorialVisible = false;  // Track tutorial panel visibility
 
-// Mix Mode State
-let mixMode = false;              // Single Pattern vs Mix Patterns mode
+// Mode State - 'single' | 'mix' | 'dataset'
+let currentMode = 'single';
 let mixOperation = 'sum';         // 'sum' or 'append'
 let mixComponents = [];           // [{name, data, changePoints, params, 
count}]
 let mixedData = null;             // Combined data array
 let mixedChangePoints = [];       // Merged ground truth change points
 let tileBadges = {};              // Track count badges on tiles
 
+// Dataset Mode State
+let bundledDatasets = [];         // [{name, title, description}, ...]
+let availableAlgorithms = {};     // {split: {title, available}, ...}
+let datasetSeriesCache = {};      // name -> {series, timestamps?}
+
+// Helper that reads better at call sites than `currentMode === 'mix'`.
+const isMixMode = () => currentMode === 'mix';
+
+// Algorithm colors used when overlaying multiple algorithms on one chart.
+const ALGO_COLORS = {
+    split:         '#1f77b4',
+    orig:          '#d62728',
+    deterministic: '#2ca02c',
+};
+
+// In-flight AbortController for /api/compare so out-of-order responses can't
+// land after a newer one and desync the chart from the controls.
+let datasetInflight = null;
+
 // DOM Elements - Data Generation
 const generatorGrid = document.getElementById('generator-grid');
 const lengthSlider = document.getElementById('length-slider');
@@ -29,15 +48,26 @@ const lengthMax = document.getElementById('length-max');
 const seedInput = document.getElementById('seed-input');
 const dynamicParams = document.getElementById('dynamic-params');
 
-// DOM Elements - Mix Mode
+// DOM Elements - Mode toggle
 const modeSingleBtn = document.getElementById('mode-single-btn');
 const modeMixBtn = document.getElementById('mode-mix-btn');
+const modeDatasetBtn = document.getElementById('mode-dataset-btn');
 const mixInfo = document.getElementById('mix-info');
 const mixRecipe = document.getElementById('mix-recipe');
 const clearMixBtn = document.getElementById('clear-mix-btn');
 
+// DOM Elements - Dataset mode
+const datasetSection = document.getElementById('dataset-section');
+const datasetSourceSelect = document.getElementById('dataset-source');
+const datasetDescription = document.getElementById('dataset-description');
+const customInputWrapper = document.getElementById('custom-input-wrapper');
+const customInput = document.getElementById('custom-input');
+const datasetStatusEl = document.getElementById('dataset-status');
+const datasetResultsSection = document.getElementById('dataset-results');
+const datasetResultsBody = document.getElementById('dataset-results-body');
+
 // DOM Elements - Otava Controls
-const runOtavaCheckbox = document.getElementById('run-otava-checkbox');
+const otavaAlgoCheckboxes = document.querySelectorAll('.otava-algo-checkbox');
 const windowLenInput = document.getElementById('window-len-input');
 const maxPvalueInput = document.getElementById('max-pvalue-input');
 const yMinInput = document.getElementById('y-min-input');
@@ -79,6 +109,25 @@ const stdDevNumInput = 
document.getElementById('stddev-num-input');
 const DEFAULT_TOLERANCE = 0;  // Exact match for True Positive
 const CLOSE_MATCH_TOLERANCE = 5;  // Within 5 points for Close Match
 
+/** Return the set of Otava algorithms the user has enabled. */
+function getEnabledOtavaAlgorithms() {
+    return Array.from(otavaAlgoCheckboxes)
+        .filter(el => el.checked && !el.disabled)
+        .map(el => el.dataset.algo);
+}
+
+/** True if at least one Otava algorithm is enabled. */
+function isOtavaEnabled() {
+    return getEnabledOtavaAlgorithms().length > 0;
+}
+
+/** Primary algorithm — the first checked one (drives the chart annotations
+ *  + accuracy metrics in single/mix mode). Defaults to 'split'. */
+function primaryOtavaAlgorithm() {
+    const enabled = getEnabledOtavaAlgorithms();
+    return enabled[0] || 'split';
+}
+
 // DOM Elements - Actions
 const generateBtn = document.getElementById('generate-btn');
 const showAllBtn = document.getElementById('show-all-btn');
@@ -128,8 +177,16 @@ const metricsTutorialPanel = 
document.getElementById('metrics-tutorial-panel');
 
 // Initialize
 document.addEventListener('DOMContentLoaded', async () => {
-    await Promise.all([loadGenerators(), loadAnalysisMethods()]);
+    document.body.setAttribute('data-mode', 'single');
+    await Promise.all([
+        loadGenerators(),
+        loadAnalysisMethods(),
+        loadDatasets(),
+        loadAvailableAlgorithms(),
+    ]);
     await populateGeneratorGrid();
+    populateDatasetUI();
+    refreshDatasetSourceDescription();
     setupEventListeners();
     setupTutorialHandlers();
     updateGeneratorInfo();
@@ -217,7 +274,7 @@ async function populateGeneratorGrid() {
 
     const generatorNames = Object.keys(generators);
 
-    if (mixMode) {
+    if (isMixMode()) {
         // Mix mode layout: Single row with clean patterns + noise + operation 
toggle
         const mixRow1Order = [
             'constant',
@@ -344,15 +401,15 @@ async function populateGeneratorGrid() {
     }
 
     // Apply mix mode class to grid
-    generatorGrid.classList.toggle('mix-mode', mixMode);
+    generatorGrid.classList.toggle('mix-mode', isMixMode());
 }
 
 /**
  * Create a generator tile element
  */
-function createGeneratorTile(name, info, preview, isMixMode) {
+function createGeneratorTile(name, info, preview, forMixGrid) {
     const tile = document.createElement('div');
-    tile.className = 'generator-tile' + (!isMixMode && name === 
selectedGenerator ? ' selected' : '');
+    tile.className = 'generator-tile' + (!forMixGrid && name === 
selectedGenerator ? ' selected' : '');
     tile.dataset.generator = name;
 
     // Preview container
@@ -371,7 +428,7 @@ function createGeneratorTile(name, info, preview, 
isMixMode) {
     tile.appendChild(label);
 
     // Click handler - different for mix mode vs single mode
-    if (isMixMode) {
+    if (forMixGrid) {
         tile.addEventListener('click', () => addToMix(name));
     } else {
         tile.addEventListener('click', () => selectGenerator(name));
@@ -379,7 +436,7 @@ function createGeneratorTile(name, info, preview, 
isMixMode) {
 
     // Create mini chart
     if (preview && preview.data) {
-        createTileChart(canvas, preview.data, !isMixMode && name === 
selectedGenerator);
+        createTileChart(canvas, preview.data, !forMixGrid && name === 
selectedGenerator);
     }
 
     return tile;
@@ -695,13 +752,23 @@ function setupEventListeners() {
     generateBtn.addEventListener('click', generateData);
     showAllBtn.addEventListener('click', showAllPatterns);
 
-    // Mix mode controls
-    modeSingleBtn.addEventListener('click', () => toggleMixMode(false));
-    modeMixBtn.addEventListener('click', () => toggleMixMode(true));
+    // Mode toggle
+    modeSingleBtn.addEventListener('click', () => setMode('single'));
+    modeMixBtn.addEventListener('click', () => setMode('mix'));
+    modeDatasetBtn.addEventListener('click', () => setMode('dataset'));
     clearMixBtn.addEventListener('click', clearMix);
 
+    // Dataset mode controls
+    datasetSourceSelect.addEventListener('change', () => {
+        refreshDatasetSourceDescription();
+        if (currentMode === 'dataset') runDatasetAnalysis();
+    });
+    customInput.addEventListener('change', () => {
+        if (currentMode === 'dataset') runDatasetAnalysis();
+    });
+
     // Otava controls
-    runOtavaCheckbox.addEventListener('change', refreshDisplay);
+    otavaAlgoCheckboxes.forEach(cb => cb.addEventListener('change', 
refreshDisplay));
     windowLenInput.addEventListener('change', refreshDisplay);
     maxPvalueInput.addEventListener('change', refreshDisplay);
 
@@ -740,7 +807,7 @@ function setupEventListeners() {
 
 // Update generator info display
 function updateGeneratorInfo() {
-    if (mixMode && mixComponents.length > 0) {
+    if (isMixMode() && mixComponents.length > 0) {
         // Mix mode with components
         const totalCPs = mixedChangePoints ? mixedChangePoints.filter(cp => 
cp.type !== 'outlier').length : 0;
         generatorTitle.textContent = 'Mixed Pattern';
@@ -783,7 +850,7 @@ function updateGeneratorInfo() {
 
 // Update dynamic parameter inputs
 function updateDynamicParams() {
-    if(mixMode) updateMixParams();
+    if(isMixMode()) updateMixParams();
     else updateSingleParams();
 }
 
@@ -822,7 +889,7 @@ function renderParamWidgets(info, containerDiv, 
bindComponent){
 
             function dynamicParamChanged(ev) {
                 // console.log(ev);
-                if(mixMode && bindComponent){
+                if(isMixMode() && bindComponent){
                     bindComponent.params[paramName] = ev.target.value;
                     asyncRedraw(bindComponent);
                 }
@@ -1164,13 +1231,14 @@ async function generateData() {
     const name = selectedGenerator;
     const length = lengthInput.value;
     const seed = seedInput.value;
-    const runOtava = runOtavaCheckbox.checked;
+    const runOtava = isOtavaEnabled();
 
     // Build query params
     const params = new URLSearchParams({
         length,
         seed,
         run_otava: runOtava,
+        otava_algorithm: primaryOtavaAlgorithm(),
         window_len: windowLenInput.value,
         max_pvalue: maxPvalueInput.value,
         tolerance: DEFAULT_TOLERANCE,
@@ -1409,7 +1477,7 @@ function updateChart(data) {
 
     // Track which is the last chart for showing X-axis
     const enabledMethods = [];
-    if (runOtavaCheckbox.checked) enabledMethods.push('otava');
+    if (isOtavaEnabled()) enabledMethods.push('otava');
     if (runMa) enabledMethods.push('ma');
     if (runBoundary) enabledMethods.push('boundary');
     if (runThreshold) enabledMethods.push('threshold');
@@ -1417,7 +1485,7 @@ function updateChart(data) {
     if (runStdDev) enabledMethods.push('stdDev');
 
     // Create Otava chart if enabled
-    if (runOtavaCheckbox.checked) {
+    if (isOtavaEnabled()) {
         const { tp: otavaTp, cm: otavaCm, fp: otavaFp, exactMatches: 
otavaExact, closeMatches: otavaClose } = otavaClassification;
         const canvas = createChartContainer('otava', 'Otava Analysis', 
'#2563eb', otavaTp, otavaCm, otavaFp);
         const ctx = canvas.getContext('2d');
@@ -1812,7 +1880,7 @@ function updateChart(data) {
 
     // Store all results for accuracy metrics display
     data._methodResults = {
-        otava: runOtavaCheckbox.checked ? {
+        otava: isOtavaEnabled() ? {
             name: 'Otava',
             classification: otavaClassification,
             detectedIndices: detectedIndices
@@ -2220,7 +2288,9 @@ async function showAllPatterns() {
  * Refresh the current display - calls the appropriate update function based 
on mode
  */
 function refreshDisplay() {
-    if (mixMode && mixComponents.length > 0) {
+    if (currentMode === 'dataset') {
+        runDatasetAnalysis();
+    } else if (currentMode === 'mix' && mixComponents.length > 0) {
         computeAndDisplayMixedData();
     } else {
         generateData();
@@ -2228,36 +2298,372 @@ function refreshDisplay() {
 }
 
 /**
- * Toggle between Single Pattern and Mix Patterns mode
+ * Switch between the three top-level modes: 'single' | 'mix' | 'dataset'.
  */
-function toggleMixMode(enable) {
-    mixMode = enable;
+function setMode(mode) {
+    if (!['single', 'mix', 'dataset'].includes(mode)) return;
+    currentMode = mode;
+
+    // Body data-attr drives CSS that hides ground-truth sections in dataset 
mode.
+    document.body.setAttribute('data-mode', mode);
+
+    // Update button active state
+    modeSingleBtn.classList.toggle('active', mode === 'single');
+    modeMixBtn.classList.toggle('active', mode === 'mix');
+    modeDatasetBtn.classList.toggle('active', mode === 'dataset');
+
+    // Show/hide the right top section
+    const generatorSection = document.querySelector('.generator-grid-section');
+    if (generatorSection) generatorSection.classList.toggle('hidden', mode === 
'dataset');
+    datasetSection.classList.toggle('hidden', mode !== 'dataset');
+
+    // Update the section heading next to the mode toggle.
+    const title = document.getElementById('mode-section-title');
+    if (title) {
+        title.textContent =
+            mode === 'dataset' ? 'Dataset' :
+            mode === 'mix'     ? 'Mix Patterns' :
+                                 'Select Pattern';
+    }
 
-    // Update button states
-    modeSingleBtn.classList.toggle('active', !enable);
-    modeMixBtn.classList.toggle('active', enable);
+    // Mix-info banner visible only in mix mode
+    mixInfo.classList.toggle('hidden', mode !== 'mix');
+    generatorGrid.classList.toggle('mix-mode', mode === 'mix');
 
-    // Toggle mix info visibility
-    mixInfo.classList.toggle('hidden', !enable);
+    if (mode === 'mix') {
+        updateMixDisplay();
+        populateGeneratorGrid();
+    } else if (mode === 'single') {
+        clearMix();
+        populateGeneratorGrid();
+        if (selectedGenerator) generateData();
+    } else {
+        // Dataset mode: hide multi-chart and clear mix state silently.
+        multiChartContainer.classList.add('hidden');
+        runDatasetAnalysis();
+    }
+}
 
-    // Toggle grid class
-    generatorGrid.classList.toggle('mix-mode', enable);
+/* ====================================================================
+   Dataset Mode
+   ==================================================================== */
 
-    // Clear mix state when switching modes- or maybe not...
-    if (enable) {
-        //clearMix();
-        updateMixDisplay();
+async function loadDatasets() {
+    try {
+        const r = await fetch('/api/datasets');
+        const body = await r.json();
+        bundledDatasets = body.datasets || [];
+    } catch (e) {
+        console.error('Failed to load datasets:', e);
+        bundledDatasets = [];
+    }
+}
+
+async function loadAvailableAlgorithms() {
+    try {
+        const r = await fetch('/api/algorithms');
+        const body = await r.json();
+        availableAlgorithms = body.algorithms || {};
+    } catch (e) {
+        console.error('Failed to load algorithms:', e);
+        availableAlgorithms = {};
+    }
+}
+
+function populateDatasetUI() {
+    // Dataset source dropdown.
+    datasetSourceSelect.innerHTML = '';
+    bundledDatasets.forEach(ds => {
+        const opt = document.createElement('option');
+        opt.value = ds.name;
+        opt.textContent = ds.title;
+        datasetSourceSelect.appendChild(opt);
+    });
+    const customOpt = document.createElement('option');
+    customOpt.value = '__custom__';
+    customOpt.textContent = 'Custom (paste below)';
+    datasetSourceSelect.appendChild(customOpt);
+
+    // Disable Otava-panel checkboxes for algorithms the installed otava 
doesn't expose.
+    for (const [name, info] of Object.entries(availableAlgorithms)) {
+        const cb = document.getElementById(`otava-algo-${name}`);
+        if (!cb) continue;
+        if (!info.available) {
+            cb.checked = false;
+            cb.disabled = true;
+            // Append a sibling indicator instead of overwriting the existing
+            // .algo-hint, which may contain an inline link (e.g. PR #154).
+            if (!cb.parentElement.querySelector('.algo-unavail')) {
+                const span = document.createElement('span');
+                span.className = 'algo-unavail';
+                span.textContent = '(not in installed otava)';
+                cb.parentElement.appendChild(span);
+            }
+        }
+    }
+}
+
+async function loadCurrentDatasetSeries() {
+    const choice = datasetSourceSelect.value;
+    if (choice === '__custom__') {
+        const { series, dropped } = parseCustomSeries(customInput.value);
+        return { name: 'custom', title: 'Custom', series, dropped };
+    }
+    if (!datasetSeriesCache[choice]) {
+        const r = await fetch(`/api/datasets/${encodeURIComponent(choice)}`);
+        if (!r.ok) throw new Error(`Failed to load ${choice}: ${r.status}`);
+        datasetSeriesCache[choice] = await r.json();
+    }
+    return datasetSeriesCache[choice];
+}
+
+function parseCustomSeries(text) {
+    text = (text || '').trim();
+    if (!text) return { series: [], dropped: 0 };
+    let raw;
+    if (text.startsWith('[')) {
+        try { raw = JSON.parse(text).map(Number); }
+        catch (e) { raw = 
text.split(/[^0-9eE.\-+]+/).filter(Boolean).map(Number); }
     } else {
-        // Clear badges and restore normal tile behavior
-        clearMix();
-        // Re-render the selected generator in single mode
-        if (selectedGenerator) {
-            generateData();
+        raw = text.split(/[^0-9eE.\-+]+/).filter(Boolean).map(Number);
+    }
+    const series = raw.filter(v => Number.isFinite(v));
+    return { series, dropped: raw.length - series.length };
+}
+
+function refreshDatasetSourceDescription() {
+    const choice = datasetSourceSelect.value;
+    if (choice === '__custom__') {
+        customInputWrapper.classList.remove('hidden');
+        datasetDescription.textContent = 'Paste your own numeric series.';
+        return;
+    }
+    customInputWrapper.classList.add('hidden');
+    const ds = bundledDatasets.find(d => d.name === choice);
+    datasetDescription.textContent = ds ? ds.description : '';
+}
+
+function setDatasetStatus(msg, isError = false) {
+    if (!msg) {
+        datasetStatusEl.hidden = true;
+        datasetStatusEl.textContent = '';
+        return;
+    }
+    datasetStatusEl.hidden = false;
+    datasetStatusEl.textContent = msg;
+    datasetStatusEl.classList.toggle('dataset-status--error', !!isError);
+}
+
+function emptyResultsRow(message) {
+    datasetResultsBody.replaceChildren();
+    const tr = document.createElement('tr');
+    const td = document.createElement('td');
+    td.colSpan = 3;
+    td.style.color = '#999';
+    td.textContent = message;
+    tr.appendChild(td);
+    datasetResultsBody.appendChild(tr);
+    datasetResultsSection.classList.remove('hidden');
+}
+
+async function runDatasetAnalysis() {
+    // Claim the in-flight slot up front so every early-return path leaves
+    // datasetInflight in a clean state and the next call's abort() is 
meaningful.
+    if (datasetInflight) datasetInflight.abort();
+    const ctrl = new AbortController();
+    datasetInflight = ctrl;
+    const stillCurrent = () => datasetInflight === ctrl;
+
+    let series;
+    let droppedTokens = 0;
+    try {
+        const ds = await loadCurrentDatasetSeries();
+        series = ds.series;
+        droppedTokens = ds.dropped || 0;
+    } catch (e) {
+        if (stillCurrent()) {
+            datasetInflight = null;
+            setDatasetStatus(`Failed to load series: ${e.message || e}`, true);
         }
+        return;
     }
+    if (!stillCurrent()) return;
+
+    const tooFewPoints = !series || series.length < 5;
+    const noAlgos = !tooFewPoints && getEnabledOtavaAlgorithms().length === 0;
 
-    // Rebuild grid for mix mode layout
-    populateGeneratorGrid();
+    // Compose the status: token-warning + (optional) terminal hint, so an
+    // input problem isn't masked by the "need 5 points" message and vice 
versa.
+    const parts = [];
+    if (droppedTokens > 0) parts.push(`Ignored ${droppedTokens} non-numeric 
token(s).`);
+    if (tooFewPoints) parts.push('Need at least 5 numeric points.');
+    setDatasetStatus(parts.join(' '), parts.length > 0);
+
+    if (tooFewPoints) {
+        emptyResultsRow('Need at least 5 numeric points.');
+        datasetInflight = null;
+        return;
+    }
+    if (noAlgos) {
+        renderDatasetChart(series, {});
+        emptyResultsRow('Pick at least one algorithm in the Otava Analysis 
panel.');
+        datasetInflight = null;
+        return;
+    }
+
+    const algos = getEnabledOtavaAlgorithms();
+    const params = new URLSearchParams({
+        window_len: windowLenInput.value,
+        max_pvalue: maxPvalueInput.value,
+        min_magnitude: '0',
+    });
+
+    try {
+        document.body.classList.add('loading');
+        const r = await fetch(`/api/compare?${params}`, {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ data: series, algorithms: algos }),
+            signal: ctrl.signal,
+        });
+        if (!r.ok) {
+            const err = await r.json().catch(() => ({}));
+            throw new Error(err.error || `HTTP ${r.status}`);
+        }
+        const body = await r.json();
+        if (!stillCurrent()) return;  // superseded; let the newer call render
+        renderDatasetChart(series, body.results);
+        renderDatasetResultsTable(body.results);
+        updateDatasetStats(series, body.results);
+        datasetResultsSection.classList.remove('hidden');
+    } catch (e) {
+        if (e.name === 'AbortError') return;  // expected when superseded
+        if (stillCurrent()) setDatasetStatus(`Compare failed: ${e.message || 
e}`, true);
+    } finally {
+        if (stillCurrent()) {
+            datasetInflight = null;
+            document.body.classList.remove('loading');
+        }
+    }
+}
+
+function renderDatasetChart(series, resultsByAlgo) {
+    stackedCharts.forEach(c => c.destroy());
+    stackedCharts = [];
+    stackedChartsContainer.innerHTML = '';
+
+    const container = document.createElement('div');
+    container.className = 'stacked-chart';
+    const canvas = document.createElement('canvas');
+    canvas.id = 'canvas-dataset';
+    container.appendChild(canvas);
+    stackedChartsContainer.appendChild(container);
+
+    const labels = series.map((_, i) => i);
+    const annotations = {};
+    for (const [algo, info] of Object.entries(resultsByAlgo || {})) {
+        const color = ALGO_COLORS[algo] || '#888';
+        for (const idx of (info.indices || [])) {
+            annotations[`${algo}-${idx}`] = {
+                type: 'line',
+                xMin: idx, xMax: idx,
+                borderColor: color,
+                borderWidth: 2,
+                borderDash: [4, 4],
+            };
+        }
+    }
+
+    const chart = new Chart(canvas.getContext('2d'), {
+        type: 'line',
+        data: {
+            labels,
+            datasets: [{
+                label: 'Series',
+                data: series,
+                borderColor: '#444',
+                backgroundColor: 'rgba(0,0,0,0)',
+                pointRadius: 1.5,
+                borderWidth: 1,
+                tension: 0,
+            }],
+        },
+        options: {
+            responsive: true,
+            maintainAspectRatio: false,
+            scales: {
+                x: { title: { display: true, text: 'Index' } },
+                y: { title: { display: true, text: 'Value' } },
+            },
+            plugins: {
+                legend: { position: 'top' },
+                annotation: { annotations },
+            },
+        },
+    });
+    stackedCharts.push(chart);
+}
+
+function renderDatasetResultsTable(resultsByAlgo) {
+    // Build rows as DOM nodes so server-supplied strings (algorithm names,
+    // error messages) can't break out and inject HTML.
+    datasetResultsBody.replaceChildren();
+    const entries = Object.entries(resultsByAlgo);
+    if (entries.length === 0) {
+        const tr = document.createElement('tr');
+        const td = document.createElement('td');
+        td.colSpan = 3;
+        td.style.color = '#999';
+        td.textContent = 'No algorithms selected.';
+        tr.appendChild(td);
+        datasetResultsBody.appendChild(tr);
+        return;
+    }
+    for (const [algo, info] of entries) {
+        const color = ALGO_COLORS[algo] || '#888';
+        const indices = info.indices || [];
+        const tr = document.createElement('tr');
+
+        const tdName = document.createElement('td');
+        const swatch = document.createElement('span');
+        swatch.className = 'swatch';
+        swatch.style.background = color;
+        tdName.appendChild(swatch);
+        tdName.appendChild(document.createTextNode(algo));
+        if (info.error) {
+            const span = document.createElement('span');
+            span.style.color = '#900';
+            span.textContent = ` (${info.error})`;
+            tdName.appendChild(span);
+        }
+        tr.appendChild(tdName);
+
+        const tdCount = document.createElement('td');
+        tdCount.textContent = String(indices.length);
+        tr.appendChild(tdCount);
+
+        const tdIdx = document.createElement('td');
+        const code = document.createElement('code');
+        code.textContent = indices.length ? indices.join(', ') : '—';
+        tdIdx.appendChild(code);
+        tr.appendChild(tdIdx);
+
+        datasetResultsBody.appendChild(tr);
+    }
+}
+
+function updateDatasetStats(series, resultsByAlgo) {
+    const n = series.length;
+    const mean = series.reduce((a, b) => a + b, 0) / n;
+    const variance = series.reduce((a, v) => a + (v - mean) ** 2, 0) / n;
+    const std = Math.sqrt(variance);
+    statLength.textContent = n.toString();
+    statMean.textContent = mean.toFixed(2);
+    statStd.textContent = std.toFixed(2);
+    statCpTruth.textContent = '—';
+    const totalDetected = Object.values(resultsByAlgo || {})
+        .reduce((a, info) => a + (info.indices?.length || 0), 0);
+    statCpDetected.textContent = totalDetected.toString();
 }
 
 /**
@@ -2468,7 +2874,7 @@ function clearMix() {
     updateMixDisplay();
     updateTileBadges();
 
-    if (mixMode) {
+    if (isMixMode()) {
         // Clear charts
         stackedChartsContainer.innerHTML = `
             <div class="stacked-chart" style="text-align: center; padding: 
2rem;">
@@ -2658,7 +3064,7 @@ async function computeAndDisplayMixedData() {
     };
 
     // Run Otava analysis on mixed data if enabled
-    if (runOtavaCheckbox.checked) {
+    if (isOtavaEnabled()) {
         try {
             const params = new URLSearchParams({
                 window_len: windowLenInput.value,
@@ -2773,7 +3179,7 @@ function updateTileBadges() {
         tile.classList.remove('in-mix');
     });
 
-    if (!mixMode) return;
+    if (!isMixMode()) return;
 
     // Add badges for components in mix
     const sumClicks = {};
diff --git a/src/otava_test_data/web/templates/index.html 
b/src/otava_test_data/web/templates/index.html
index 7649ad0..e0c9057 100644
--- a/src/otava_test_data/web/templates/index.html
+++ b/src/otava_test_data/web/templates/index.html
@@ -6,7 +6,7 @@
     <title>Otava Test Data Visualizer</title>
     <script src="https://cdn.jsdelivr.net/npm/chart.js";></script>
     <script 
src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation";></script>
-    <link rel="stylesheet" href="/static/css/style.css">
+    <link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
 </head>
 <body>
     <header>
@@ -53,15 +53,20 @@
     </div>
 
     <main>
-        <!-- Generator Selection Grid -->
-        <section class="generator-grid-section">
+        <!-- Mode toggle stays visible in every mode -->
+        <section class="mode-toggle-section">
             <div class="section-header">
-                <h3 class="section-title">Select Pattern</h3>
+                <h3 class="section-title" id="mode-section-title">Select 
Pattern</h3>
                 <div class="mode-toggle">
                     <button class="mode-btn active" data-mode="single" 
id="mode-single-btn">Single Pattern</button>
                     <button class="mode-btn" data-mode="mix" 
id="mode-mix-btn">Mix Patterns</button>
+                    <button class="mode-btn" data-mode="dataset" 
id="mode-dataset-btn">Dataset</button>
                 </div>
             </div>
+        </section>
+
+        <!-- Generator Selection Grid -->
+        <section class="generator-grid-section">
             <div class="mix-info hidden" id="mix-info">
                 <span class="mix-recipe" id="mix-recipe">Click patterns to 
add...</span>
                 <button class="clear-mix-btn" id="clear-mix-btn">Clear</button>
@@ -71,8 +76,28 @@
             </div>
         </section>
 
+        <!-- Dataset Mode: real-world preset or pasted series -->
+        <section class="dataset-section hidden" id="dataset-section">
+            <div class="dataset-row">
+                <label class="dataset-label">
+                    Source:
+                    <select id="dataset-source"></select>
+                </label>
+                <p id="dataset-description" class="dataset-description"></p>
+                <label id="custom-input-wrapper" class="dataset-custom hidden">
+                    Custom series (numbers, comma- or whitespace-separated, or 
a JSON array):
+                    <textarea id="custom-input" rows="4" placeholder="e.g. 
100, 102, 99, 110, ..."></textarea>
+                </label>
+            </div>
+            <p class="dataset-hint">
+                Pick which algorithms to overlay in the
+                <strong>Otava Analysis</strong> panel below.
+            </p>
+            <p class="dataset-status" id="dataset-status" hidden></p>
+        </section>
+
         <!-- Dynamic Parameters -->
-        <section class="controls top-controls" id="params-section">
+        <section class="controls top-controls hide-in-dataset" 
id="params-section">
             <div id="dynamic-params" class="dynamic-params">
                 <!-- Dynamic parameters will be inserted here -->
             </div>
@@ -88,7 +113,7 @@
             <h3 class="section-title">Analysis Controls</h3>
 
             <div class="analysis-toolbar">
-                <div class="control-actions">
+                <div class="control-actions hide-in-dataset">
                     <button id="generate-btn" 
class="btn-primary">Analyse</button>
                     <button id="show-all-btn" class="btn-secondary">Show All 
Graphs</button>
                 </div>
@@ -118,11 +143,29 @@
                 <!-- Otava Analysis Controls -->
                 <div class="controls otava-controls">
                     <div class="panel-header">
-                        <label for="run-otava-checkbox" 
class="panel-title">Otava Analysis</label>
-                        <input type="checkbox" id="run-otava-checkbox" checked>
+                        <span class="panel-title">Otava Analysis</span>
                         <button class="method-help-btn" data-method="otava" 
title="Learn about Otava">?</button>
                     </div>
 
+                    <div class="otava-algos" id="otava-algos">
+                        <label class="otava-algo-row">
+                            <input type="checkbox" class="otava-algo-checkbox"
+                                   data-algo="split" id="otava-algo-split" 
checked>
+                            split-edivisive <span 
class="algo-hint">(default)</span>
+                        </label>
+                        <label class="otava-algo-row">
+                            <input type="checkbox" class="otava-algo-checkbox"
+                                   data-algo="orig" id="otava-algo-orig">
+                            orig-edivisive <span 
class="algo-hint">(permutation test)</span>
+                        </label>
+                        <label class="otava-algo-row">
+                            <input type="checkbox" class="otava-algo-checkbox"
+                                   data-algo="deterministic" 
id="otava-algo-deterministic">
+                            deterministic-edivisive
+                            <span class="algo-hint">(<a 
href="https://github.com/apache/otava/pull/154"; target="_blank">PR 
#154</a>)</span>
+                        </label>
+                    </div>
+
                     <div class="panel-fields">
                         <div class="control-group">
                             <label for="window-len-input" 
class="param-label">Window Length</label>
@@ -152,7 +195,7 @@
                 </div>
 
                 <!-- Moving Average Analysis Controls -->
-                <div class="controls ma-controls">
+                <div class="controls ma-controls hide-in-dataset">
                     <div class="panel-header">
                         <label for="run-ma-checkbox" 
class="panel-title">Moving Average Analysis</label>
                         <input type="checkbox" id="run-ma-checkbox">
@@ -187,7 +230,7 @@
                 </div>
 
                 <!-- Boundary Analysis Controls -->
-                <div class="controls boundary-controls">
+                <div class="controls boundary-controls hide-in-dataset">
                     <div class="panel-header">
                         <label for="run-boundary-checkbox" 
class="panel-title">Boundary Analysis</label>
                         <input type="checkbox" id="run-boundary-checkbox">
@@ -219,7 +262,7 @@
                 </div>
 
                 <!-- Threshold Based Alerts Controls -->
-                <div class="controls threshold-controls">
+                <div class="controls threshold-controls hide-in-dataset">
                     <div class="panel-header">
                         <label for="run-threshold-checkbox" 
class="panel-title">Threshold Alerts</label>
                         <input type="checkbox" id="run-threshold-checkbox">
@@ -237,7 +280,7 @@
                 </div>
 
                 <!-- Sliding Window Controls -->
-                <div class="controls sliding-window-controls">
+                <div class="controls sliding-window-controls hide-in-dataset">
                     <div class="panel-header">
                         <label for="run-sliding-window-checkbox" 
class="panel-title">Sliding Window</label>
                         <input type="checkbox" 
id="run-sliding-window-checkbox">
@@ -259,7 +302,7 @@
                 </div>
 
                 <!-- Std Dev Controls -->
-                <div class="controls stddev-controls">
+                <div class="controls stddev-controls hide-in-dataset">
                     <div class="panel-header">
                         <label for="run-stddev-checkbox" 
class="panel-title">Std Dev</label>
                         <input type="checkbox" id="run-stddev-checkbox">
@@ -279,7 +322,7 @@
         </section>
 
         <!-- Generator Info -->
-        <section class="generator-info" id="generator-info">
+        <section class="generator-info hide-in-dataset" id="generator-info">
             <div class="generator-info-header">
                 <div class="generator-info-main">
                     <h3 id="generator-title">Constant</h3>
@@ -309,7 +352,7 @@
         </section>
 
         <!-- Chart Legend -->
-        <section class="chart-legend">
+        <section class="chart-legend hide-in-dataset">
             <div class="legend-item">
                 <span class="legend-line ground-truth"></span>
                 <span>Ground Truth</span>
@@ -329,7 +372,7 @@
         </section>
 
         <!-- Accuracy Metrics -->
-        <section class="accuracy-metrics" id="accuracy-metrics">
+        <section class="accuracy-metrics hide-in-dataset" 
id="accuracy-metrics">
             <h3>Detection Accuracy</h3>
             <table class="accuracy-table">
                 <thead>
@@ -375,7 +418,7 @@
         </section>
 
         <!-- Change Points Comparison Table -->
-        <section class="change-points-detail" id="change-points-detail">
+        <section class="change-points-detail hide-in-dataset" 
id="change-points-detail">
             <h3>Change Points Comparison</h3>
             <div class="comparison-tables">
                 <div class="table-wrapper">
@@ -412,6 +455,17 @@
             </div>
         </section>
 
+        <!-- Dataset Mode: per-algorithm detected change-point table -->
+        <section class="dataset-results hidden" id="dataset-results">
+            <h3>Detected change-point indices per algorithm</h3>
+            <table class="dataset-results-table">
+                <thead>
+                    <tr><th style="width:30%;">Algorithm</th><th 
style="width:8%;">Count</th><th>Indices</th></tr>
+                </thead>
+                <tbody id="dataset-results-body"></tbody>
+            </table>
+        </section>
+
         <!-- Multi-chart view for "Show All" -->
         <section class="multi-chart-container hidden" 
id="multi-chart-container">
             <h2>All Test Patterns - Otava Comparison</h2>
@@ -431,6 +485,6 @@
         </p>
     </footer>
 
-    <script src="/static/js/app.js"></script>
+    <script src="/static/js/app.js?v={{ version }}"></script>
 </body>
 </html>

Reply via email to