diff --git a/delphi/tests/replay_harness/clj_crosslang.py b/delphi/tests/replay_harness/clj_crosslang.py
new file mode 100644
index 0000000000..e43d12e258
--- /dev/null
+++ b/delphi/tests/replay_harness/clj_crosslang.py
@@ -0,0 +1,160 @@
+"""Cross-language store reading — Phase H-B (Clojure Mode A) support.
+
+The Clojure driver (``math/dev/replay.clj``) writes its per-step ``math_main``
+view as ``clj/step-NNN.blob.json`` — the file *is* the raw ``prep-main`` blob
+(design §7), NOT the ``{index, …, blob}`` payload wrapper the Python store uses
+under ``py/step-NNN.json``. This module bridges the two so the EXISTING
+:func:`polismath.replay.stepcompare.compare_recordings` can diff a Clojure
+recording against a Python one with zero changes to production code.
+
+The bridge is a shim: :func:`clj_recording_to_py_store` re-wraps each raw clj
+blob into the py-store payload shape under a throwaway ``py/`` directory, after
+which ``compare_recordings(shim_dir, py_dir, engine="py")`` runs verbatim.
+
+This is deliberately kept in ``tests/`` (not in ``polismath/replay/``) — it is
+harness glue for the H-B cross-language smoke and its regression test, not a
+production API.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+# ---------------------------------------------------------------------------
+# prep-main key whitelist + projection.
+# ---------------------------------------------------------------------------
+# The 23 keys Clojure's prep-main emits into math_main (conv_man.clj:43-74),
+# taken VERBATIM from the committed vw cold-start blob
+# (delphi/real_data/*-vw/*math_blob_cold_start.json — all kebab-case). Python's
+# to_dict() spells several of these snake_case (comment_priorities,
+# group_clusters, …) and adds Python-only keys (comment_count, participant_info,
+# vote_stats, proj, moderation, …). Without canonicalising, the comparer's
+# normalize_key (bare str(), comparer.py:611) treats kebab vs snake as DIFFERENT
+# keys, so the values (e.g. comment-priorities) are flagged once as a key-name
+# mismatch and then silently DROPPED from the numeric diff — exactly the routing
+# surface we care about. Projecting BOTH blobs onto this whitelist with canonical
+# kebab spelling before diffing puts them on the same numeric compare path.
+PREP_MAIN_KEYS = frozenset({
+ "base-clusters", "comment-priorities", "consensus", "group-aware-consensus",
+ "group-clusters", "group-votes", "in-conv", "lastModTimestamp",
+ "lastVoteTimestamp", "meta-tids", "mod-in", "mod-out", "n", "n-cmts", "pca",
+ "repness", "subgroup-clusters", "subgroup-repness", "subgroup-votes", "tids",
+ "user-vote-counts", "votes-base", "zid",
+})
+
+
+def _kebab(key: Any) -> Any:
+ """snake_case -> kebab-case for string keys (comment_priorities ->
+ comment-priorities); non-string keys pass through unchanged."""
+ return key.replace("_", "-") if isinstance(key, str) else key
+
+
+def project_prep_main(blob: dict[str, Any]) -> dict[str, Any]:
+ """Project a math_main blob onto the prep-main 23-key whitelist, keys spelled
+ in canonical kebab-case, pulling each key's value from whichever spelling the
+ blob carries. An exact kebab key always wins over a snake alias (Python's
+ to_dict() carries BOTH ``group-clusters`` and ``group_clusters``). Keys absent
+ from the whitelist are dropped on both sides."""
+ if not isinstance(blob, dict):
+ raise TypeError(
+ f"prep-main projection expects a dict blob, got {type(blob).__name__}"
+ )
+ canon: dict[Any, Any] = {}
+ for k, v in blob.items():
+ ck = _kebab(k)
+ # First-writer-wins, but let an exact kebab spelling override a prior
+ # snake alias so the canonical prep-main value is the one compared.
+ if ck not in canon or k == ck:
+ canon[ck] = v
+ return {k: canon[k] for k in canon if k in PREP_MAIN_KEYS}
+
+
+def clj_blob_files(clj_dir: str | Path) -> list[Path]:
+ """The ``step-NNN.blob.json`` files in a clj recording dir, in step order.
+
+ Only the flat top-level blobs are returned (repeat 0 / the canonical
+ cross-language surface); ``rep-*/`` subdirs and ``.edn`` files are ignored.
+ """
+ return sorted(Path(clj_dir).glob("step-*.blob.json"))
+
+
+def load_clj_blobs(clj_dir: str | Path) -> list[dict[str, Any]]:
+ """Load the raw ``prep-main`` blobs from a clj recording dir, in step order."""
+ return [json.loads(p.read_text()) for p in clj_blob_files(clj_dir)]
+
+
+def clj_recording_to_py_store(clj_dir: str | Path, dest_dir: str | Path) -> Path:
+ """Re-wrap a clj recording's blobs into the py-store layout under ``dest_dir``.
+
+ Writes ``dest_dir/py/step-NNN.json`` with the ``{index, …, blob}`` payload
+ :func:`polismath.replay.store.load_step_blobs` expects, so the shimmed dir is
+ a drop-in first argument to ``compare_recordings(..., engine="py")``.
+ Returns ``dest_dir``.
+ """
+ dest_dir = Path(dest_dir)
+ py_dir = dest_dir / "py"
+ py_dir.mkdir(parents=True, exist_ok=True)
+ # Clear stale step payloads from a previous shim run: if the new recording
+ # has fewer steps, leftovers would read back as phantom extra steps.
+ for stale in py_dir.glob("step-*.json"):
+ stale.unlink()
+ for i, p in enumerate(clj_blob_files(clj_dir)):
+ blob = json.loads(p.read_text())
+ payload = {
+ "index": i,
+ "prev_slot": None,
+ "cut_slot": None,
+ "batch_size": None,
+ "cut_time_ms": blob.get("lastVoteTimestamp"),
+ "blob": blob,
+ "extras": {},
+ }
+ (py_dir / f"step-{i:03d}.json").write_text(json.dumps(payload, indent=2))
+ return dest_dir
+
+
+def _prep_main_projecting_comparer():
+ """A StepComparer that projects BOTH step blobs onto the prep-main whitelist
+ (canonical kebab spelling) before diffing, so Python's snake_case keys align
+ with Clojure's kebab and their VALUES land on the numeric compare path. The
+ tolerant-stat keys are kebab-canonicalised too so the float stats that
+ survive projection (repness, comment-priorities, group-aware-consensus) keep
+ their tolerant classification."""
+ from polismath.replay.stepcompare import DEFAULT_TOLERANT_STAT_KEYS, StepComparer
+
+ tolerant = frozenset(_kebab(k) for k in DEFAULT_TOLERANT_STAT_KEYS)
+
+ class PrepMainProjectingComparer(StepComparer):
+ def compare_step(self, blob_a, blob_b, index):
+ return super().compare_step(
+ project_prep_main(blob_a), project_prep_main(blob_b), index
+ )
+
+ return PrepMainProjectingComparer(tolerant_stat_keys=tolerant)
+
+
+def compare_clj_vs_py(
+ recording_dir: str | Path,
+ *,
+ shim_root: str | Path,
+ comparer=None,
+) -> dict[str, Any]:
+ """Compare the ``clj/`` and ``py/`` recordings inside one recording dir.
+
+ Shims ``recording_dir/clj`` into ``shim_root`` (py-store shape), then reuses
+ :func:`polismath.replay.stepcompare.compare_recordings` verbatim. The Clojure
+ blob is the ``a`` (golden) side, Python the ``b`` (current) side.
+
+ By default both blobs are projected onto the prep-main 23-key whitelist
+ (kebab-canonicalised) so snake vs kebab key spellings compare their VALUES
+ rather than being dropped as key-name mismatches (see :func:`project_prep_main`).
+ Pass an explicit ``comparer`` to bypass the projection.
+ """
+ from polismath.replay import stepcompare as sc
+
+ recording_dir = Path(recording_dir)
+ shim = clj_recording_to_py_store(recording_dir / "clj", shim_root)
+ cmp = comparer if comparer is not None else _prep_main_projecting_comparer()
+ return sc.compare_recordings(shim, recording_dir, engine="py", comparer=cmp)
diff --git a/delphi/tests/replay_harness/test_clj_crosslang.py b/delphi/tests/replay_harness/test_clj_crosslang.py
new file mode 100644
index 0000000000..4f0e890c49
--- /dev/null
+++ b/delphi/tests/replay_harness/test_clj_crosslang.py
@@ -0,0 +1,214 @@
+"""Cross-language store-reading tests — Phase H-B (Clojure Mode A).
+
+Verifies the shim that lets the EXISTING
+:func:`polismath.replay.stepcompare.compare_recordings` diff a Clojure
+recording (``clj/step-NNN.blob.json`` — the raw ``prep-main`` blob) against a
+Python one, without touching production code. Uses synthetic blobs only — no
+Clojure toolchain, so it stays in the fast delphi suite.
+
+The real cross-language gap measurement (Clojure driver vs Python driver on the
+vw dataset) is a manual smoke (see math/dev/replay_smoke.sh); this test locks
+the store-bridging logic those smokes rely on.
+"""
+
+import json
+from pathlib import Path
+
+import pytest
+
+from replay_harness.clj_crosslang import (
+ PREP_MAIN_KEYS,
+ clj_blob_files,
+ clj_recording_to_py_store,
+ compare_clj_vs_py,
+ load_clj_blobs,
+ project_prep_main,
+)
+from polismath.conversation.conversation import Conversation
+from polismath.replay import stepcompare as sc
+from polismath.replay.store import load_step_blobs
+
+
+def _blob(n=3, first_comp=1.0):
+ """A minimal math_main-shaped blob (prep-main key spelling)."""
+ return {
+ "zid": "t",
+ "math_tick": 30000, # ignored by the comparer
+ "n": n,
+ "n-cmts": 2,
+ "in-conv": [1, 2, 3],
+ "tids": [0, 1],
+ "pca": {"center": [0.1, 0.2], "comps": [[first_comp, 0.0], [0.0, 1.0]]},
+ "base-clusters": {"id": [0, 1], "x": [0.1, -0.1], "y": [0.2, -0.2],
+ "count": [1, 2], "members": [[1], [2, 3]]},
+ "repness": {},
+ }
+
+
+def _write_clj_recording(root: Path, blobs) -> Path:
+ """Write raw prep-main blobs as ``root/clj/step-NNN.blob.json``."""
+ clj = root / "clj"
+ clj.mkdir(parents=True, exist_ok=True)
+ for i, b in enumerate(blobs):
+ (clj / f"step-{i:03d}.blob.json").write_text(json.dumps(b))
+ return clj
+
+
+def _write_py_recording(root: Path, blobs) -> None:
+ """Write blobs in the py-store payload shape under ``root/py``."""
+ py = root / "py"
+ py.mkdir(parents=True, exist_ok=True)
+ for i, b in enumerate(blobs):
+ payload = {"index": i, "cut_slot": None, "blob": b, "extras": {}}
+ (py / f"step-{i:03d}.json").write_text(json.dumps(payload))
+
+
+def test_clj_blob_files_ordered_and_scoped(tmp_path):
+ """Only flat step-*.blob.json in order; rep-*/ and .edn are ignored."""
+ clj = _write_clj_recording(tmp_path, [_blob(), _blob(), _blob()])
+ # decoys that must NOT be picked up:
+ (clj / "step-000.edn").write_text("{}")
+ (clj / "rep-1").mkdir()
+ (clj / "rep-1" / "step-000.blob.json").write_text(json.dumps(_blob()))
+
+ files = clj_blob_files(clj)
+ assert [p.name for p in files] == [
+ "step-000.blob.json", "step-001.blob.json", "step-002.blob.json"
+ ]
+ assert len(load_clj_blobs(clj)) == 3
+
+
+def test_shim_produces_loadable_py_store(tmp_path):
+ """The shimmed dir is a drop-in for the py store's load_step_blobs."""
+ blobs = [_blob(n=3), _blob(n=4)]
+ _write_clj_recording(tmp_path, blobs)
+ shim = clj_recording_to_py_store(tmp_path / "clj", tmp_path / "shim")
+
+ loaded = load_step_blobs(shim / "py")
+ assert len(loaded) == 2
+ assert loaded[0]["n"] == 3 and loaded[1]["n"] == 4
+ # cut_time_ms falls back to lastVoteTimestamp (absent here → None), tolerated.
+ assert (shim / "py" / "step-000.json").exists()
+
+
+def test_identical_clj_and_py_recordings_match(tmp_path):
+ """clj vs py with identical content → overall match, zero divergence."""
+ blobs = [_blob(n=3), _blob(n=5)]
+ _write_clj_recording(tmp_path, blobs)
+ _write_py_recording(tmp_path, blobs)
+
+ report = compare_clj_vs_py(tmp_path, shim_root=tmp_path / "shim")
+ assert report["overall_match"] is True
+ assert report["summary"]["diverging_steps"] == 0
+
+
+def test_exact_field_divergence_is_reported(tmp_path):
+ """A count difference surfaces as an EXACT-family divergence."""
+ clj_blobs = [_blob(n=3)]
+ py_blobs = [_blob(n=99)] # participant count differs
+ _write_clj_recording(tmp_path, clj_blobs)
+ _write_py_recording(tmp_path, py_blobs)
+
+ report = compare_clj_vs_py(tmp_path, shim_root=tmp_path / "shim")
+ assert report["overall_match"] is False
+ step0 = report["per_step"][0]
+ paths = [d["path"] for d in step0["families"]["exact"]]
+ assert any(p.endswith(".n") for p in paths), paths
+
+
+def test_pca_sign_flip_absorbed_as_no_divergence(tmp_path):
+ """A pure PCA comps sign flip is absorbed (ignore_pca_sign_flip=True)."""
+ clj_blobs = [_blob(first_comp=1.0)]
+ py_blobs = [_blob(first_comp=-1.0)] # sign-flipped first component
+ _write_clj_recording(tmp_path, clj_blobs)
+ _write_py_recording(tmp_path, py_blobs)
+
+ report = compare_clj_vs_py(tmp_path, shim_root=tmp_path / "shim")
+ # The comps sign flip itself must not count as a divergence.
+ step0 = report["per_step"][0]
+ comp_divs = [
+ d for d in step0["families"]["tolerant"] + step0["families"]["exact"]
+ if ".pca.comps" in (d["path"] or "")
+ ]
+ assert comp_divs == [], comp_divs
+
+
+# --- T6: prep-main whitelist projection (kebab vs snake key alignment) --------
+def _real_py_blob():
+ """A REAL Conversation.to_dict() (2 opposing camps -> 2 groups + repness +
+ a populated comment_priorities dict), spelled SNAKE_case as Python emits it."""
+ votes = []
+ n_per, n_cmts = 8, 8
+ for p in range(n_per * 2):
+ camp = 0 if p < n_per else 1
+ for t in range(n_cmts):
+ v = (1.0 if t % 2 == 0 else -1.0) if camp == 0 else (-1.0 if t % 2 == 0 else 1.0)
+ votes.append({"pid": f"p{p}", "tid": f"c{t}", "vote": v})
+ return Conversation("t6").update_votes({"votes": votes}).to_dict()
+
+
+def test_projection_canonicalises_and_whitelists():
+ blob = _real_py_blob()
+ assert "comment_priorities" in blob and "comment-priorities" not in blob
+ proj = project_prep_main(blob)
+ # snake -> kebab, restricted to the whitelist; Python-only keys dropped.
+ assert "comment-priorities" in proj and "comment_priorities" not in proj
+ assert set(proj) <= PREP_MAIN_KEYS
+ assert "comment_count" not in proj and "participant_info" not in proj
+ # value preserved through the projection.
+ assert proj["comment-priorities"] == blob["comment_priorities"]
+
+
+def test_comment_priorities_lands_on_numeric_compare_path(tmp_path):
+ """Feed a REAL snake_case to_dict() on the PY side and a kebab prep-main blob
+ on the CLJ side whose comment-priorities differs numerically. With the
+ whitelist projection the values are COMPARED (Numeric mismatch surfaces);
+ without it they are silently dropped as a key-name mismatch (fails today)."""
+ py_blob = _real_py_blob()
+ tid = next(iter(py_blob["comment_priorities"]))
+
+ # Clojure (golden) side: the projected (kebab) blob with one priority bumped
+ # far beyond tolerance, so a faithful numeric compare MUST flag it.
+ clj_blob = project_prep_main(py_blob)
+ clj_blob["comment-priorities"] = dict(clj_blob["comment-priorities"])
+ clj_blob["comment-priorities"][tid] = clj_blob["comment-priorities"][tid] + 1000.0
+
+ _write_clj_recording(tmp_path, [clj_blob])
+ _write_py_recording(tmp_path, [py_blob])
+
+ report = compare_clj_vs_py(tmp_path, shim_root=tmp_path / "shim")
+ step0 = report["per_step"][0]
+ all_divs = step0["families"]["exact"] + step0["families"]["tolerant"]
+ cp_numeric = [
+ d for d in all_divs
+ if "comment-priorities" in (d["path"] or "")
+ and (d["reason"] or "").startswith("Numeric mismatch")
+ ]
+ assert cp_numeric, (
+ "comment-priorities value must land on the numeric compare path; "
+ f"divergences seen: {[(d['path'], d['reason']) for d in all_divs]}"
+ )
+
+
+def test_shim_clears_stale_step_files_on_rerun(tmp_path):
+ """Re-running the shim into the same dest_dir with FEWER steps must not
+ leave stale step files behind (they would show up as phantom extra steps
+ or divergences in load_step_blobs / compare_recordings)."""
+ _write_clj_recording(tmp_path, [_blob(n=3), _blob(n=4), _blob(n=5)])
+ shim = clj_recording_to_py_store(tmp_path / "clj", tmp_path / "shim")
+ assert len(load_step_blobs(shim / "py")) == 3
+
+ # Shrink the clj recording to 1 step and re-shim into the SAME dest.
+ for stale in sorted((tmp_path / "clj").glob("step-*.blob.json"))[1:]:
+ stale.unlink()
+ shim = clj_recording_to_py_store(tmp_path / "clj", tmp_path / "shim")
+ loaded = load_step_blobs(shim / "py")
+ assert len(loaded) == 1
+ assert loaded[0]["n"] == 3
+
+
+def test_projection_rejects_non_dict_blob():
+ """A non-dict blob is a malformed recording — fail fast rather than leak a
+ non-dict through the dict-typed comparer path."""
+ with pytest.raises(TypeError, match="prep-main"):
+ project_prep_main(["not", "a", "dict"]) # type: ignore[arg-type]
diff --git a/math/deps.edn b/math/deps.edn
index e040070755..44e634970c 100644
--- a/math/deps.edn
+++ b/math/deps.edn
@@ -70,7 +70,13 @@
:exec-fn user/run-with-repl}
:test
{:extra-paths ["test"]
- :main-opts ["-m" "test-runner"]}}
+ :main-opts ["-m" "test-runner"]}
+ ;; Replay harness Phase H-B — Clojure Mode A driver (dev/replay.clj).
+ ;; Loaded via -i (file path) rather than :extra-paths ["dev"] so the
+ ;; dev/user.clj namespace (which requires oz/cider) is NOT auto-loaded
+ ;; at startup — keeps the alias self-contained on the base :deps.
+ :replay
+ {:main-opts ["-i" "dev/replay.clj" "-m" "replay"]}}
;:jvm-opts ^:replace []
:jvm-opts ["-Xmx4g"]}
;:mvn/repos {"twitter4j" {:url "https://twitter4j.org/maven2"}}}
diff --git a/math/dev/replay.clj b/math/dev/replay.clj
new file mode 100644
index 0000000000..85f609e66a
--- /dev/null
+++ b/math/dev/replay.clj
@@ -0,0 +1,427 @@
+;; Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see .
+
+(ns replay
+ "Replay harness Phase H-B — the CLOJURE Mode A driver (REPLAY_HARNESS_DESIGN.md
+ §5). Pure in-process: no Postgres, no poller, no Docker.
+
+ Given a schedule JSON (§4) and a votes CSV, it seeds a conversation exactly as
+ export.clj:624-632 (`get-export-data-at-time`) does — `(-> (conv/new-conv)
+ (assoc :zid zid :meta-tids meta-tids))` — then reduces `conv/conv-update` over
+ the schedule's vote batches (the pure pattern shown at dev/user.clj:416-428 and
+ test/conversation_test.clj:40-168), recording per step:
+
+ clj/step-NNN.blob.json — the `prep-main` (conv_man.clj:43-74) key-whitelisted
+ production view, cheshire-encoded exactly as
+ postgres.clj pg-json does. This IS the `math_main`
+ blob shape and is the cross-language comparison
+ surface Python's `to_dict` targets.
+ clj/step-NNN.edn — (with --edn) full-fidelity conv state in the
+ `conv-update-dump` (conversation.clj:920) shape,
+ reloadable via `conv/load-conv-update`.
+
+ BINDING SEMANTICS (verified against source, NOT guessed):
+
+ * Vote signs — FLIP the CSV. Export CSVs (timestamp,datetime,comment-id,
+ voter-id,vote) carry EXPORT/Delphi signs (AGREE=+1); the flip is export-only
+ (export.clj:106-113). The Clojure math consumes RAW-DB signs (AGREE=-1), so
+ each CSV value v is fed as -v. Recorded as vote_sign_convention \"raw-db\".
+ (The Python driver feeds +v; each engine gets its own native convention, so
+ the OUTPUT blobs stay comparable.)
+
+ * Slicing mirrors the H-A Python slicer (schedule.py `resolve_cut_slots` /
+ `slice_schedule`) EXACTLY: sort votes stably by (t_ms, input order), KEEP
+ revotes (no dedup — later-vote-wins is resolved inside conv-update), CSV
+ timestamps are SECONDS → ms via *1000 (mirror real_data.py). Cut modes
+ vote-count | timestamp | fraction | explicit-event-index resolve the same
+ way, including Python's round-half-to-even for `fraction`.
+
+ * conv-update input shape (conversation_test.clj:18-21): a seq of maps
+ `{:pid :tid :vote :created }`. `:created` is REQUIRED
+ (the :last-vote-timestamp fnk maxes over it, conversation.clj:161-165);
+ `:zid` is taken from the seeded conv (`(or (:zid conv) (:zid (first votes)))`,
+ conversation.clj:157-159) so votes need not carry it.
+
+ * Chained warm start is IMPLICIT: the reduce threads the conv object, whose
+ `:pca :comps` become the next step's `:start-vectors` (conversation.clj:381-387).
+ That IS `warm_start: chain`. Step 0 has no prior comps → cold-start PCA uses
+ unseeded `(rand)` (pca.clj:79-82), the §9 self-jitter source measured by
+ --repeats.
+
+ * meta-tids seed from the comments CSV `is-meta`/`is_meta` column when present;
+ the public vw export has no such column, so the set is empty (documented in
+ provenance meta_tids_source).
+
+ Moderation: vw has none. `moderation` other than \"none\" raises a clear
+ not-implemented error (Mode A mod-update interleaving is deferred, design §5).
+
+ Run: cd math && clojure -M:replay --schedule --votes \\
+ --out [--repeats N] [--edn] [--zid Z] [--comments c.csv]"
+ (:require [clojure.data.csv :as csv]
+ [clojure.java.io :as io]
+ [clojure.java.shell :as shell]
+ [clojure.string :as str]
+ [clojure.tools.cli :as cli]
+ [cheshire.core :as json]
+ [com.stuartsierra.component :as component]
+ [clojure.core.matrix :as matrix]
+ [polismath.math.conversation :as conv]
+ [polismath.conv-man :as cm]
+ [polismath.components.core-matrix-boot :as cmb])
+ (:import [java.security MessageDigest]
+ [java.math BigDecimal RoundingMode]
+ [java.time Instant]))
+
+;; ---------------------------------------------------------------------------
+;; CSV → sorted vote stream (mirror ReplayDataset.build / real_data.py).
+;; ---------------------------------------------------------------------------
+
+(defn read-votes-csv
+ "Read an export votes CSV into vote maps in FILE order. Columns
+ timestamp,datetime,comment-id,voter-id,vote; timestamps are SECONDS → ms."
+ [path]
+ (with-open [rdr (io/reader path)]
+ (let [rows (csv/read-csv rdr)
+ header (first rows)
+ idx (zipmap header (range))
+ ti (idx "timestamp") ci (idx "comment-id")
+ vi (idx "voter-id") si (idx "vote")]
+ (when (some nil? [ti ci vi si])
+ (throw (ex-info "votes CSV missing a required column"
+ {:header header :need ["timestamp" "comment-id" "voter-id" "vote"]})))
+ (mapv (fn [r]
+ {:t-ms (* 1000 (Long/parseLong (str/trim (nth r ti))))
+ :pid (Long/parseLong (str/trim (nth r vi)))
+ :tid (Long/parseLong (str/trim (nth r ci)))
+ :sign (Long/parseLong (str/trim (nth r si)))})
+ (rest rows)))))
+
+(defn build-dataset
+ "Sort raw file-order vote maps stably by (t_ms, input index) and 1-index them.
+ Revotes are KEPT — dedup is never applied (design §5). Returns a vector."
+ [raw-rows]
+ (->> raw-rows
+ (map-indexed (fn [i row] (assoc row :file-idx i)))
+ (sort-by (juxt :t-ms :file-idx))
+ (map-indexed (fn [i row] (assoc row :k (inc i))))
+ vec))
+
+;; ---------------------------------------------------------------------------
+;; Cut-mode resolution (mirror schedule.py resolve_cut_slots EXACTLY).
+;; ---------------------------------------------------------------------------
+
+(def ^:private valid-modes #{"vote-count" "explicit-event-index" "timestamp" "fraction"})
+
+(defn ^long py-round
+ "Round-half-to-even to a long — matches Python's built-in round() (banker's)."
+ [^double x]
+ (.longValueExact (.setScale (BigDecimal/valueOf x) 0 RoundingMode/HALF_EVEN)))
+
+(defn count-votes-up-to
+ "#votes with t_ms <= T in a time-sorted vector (linear; n is small)."
+ [votes t-ms]
+ (count (take-while #(<= (long (:t-ms %)) (long t-ms)) votes)))
+
+(defn validate-slots
+ "Mirror ReplayDataset.validate_schedule: 1<=s<=n, strictly increasing."
+ [slots n]
+ (loop [prev 0 [s & more] slots]
+ (when s
+ (when-not (<= 1 s n)
+ (throw (ex-info (str "cut slot " s " outside 1.." n) {:slot s :n n})))
+ (when-not (> s prev)
+ (throw (ex-info (str "schedule not strictly increasing at slot " s) {:slot s})))
+ (recur s more))))
+
+(defn resolve-cut-slots
+ "Resolve a §4 `cuts` map into strictly-increasing 1-based slots in 1..n.
+ 0-slots dropped as degenerate, duplicates collapsed (== schedule.py)."
+ [votes cuts]
+ (let [mode (get cuts "mode")
+ at (get cuts "at" [])
+ n (count votes)]
+ (when-not (valid-modes mode)
+ (throw (ex-info (str "unknown cut mode " (pr-str mode)
+ "; expected one of " (sort valid-modes)) {:mode mode})))
+ (let [raw (for [a at]
+ (cond
+ (= a "end")
+ n
+ (#{"vote-count" "explicit-event-index"} mode)
+ (long a)
+ (= mode "fraction")
+ (let [f (double a)]
+ (when-not (and (< 0.0 f) (<= f 1.0))
+ (throw (ex-info (str "fraction cut " f " outside (0, 1]") {:f f})))
+ (py-round (* f n)))
+ (= mode "timestamp")
+ (count-votes-up-to votes (long a))))
+ slots (->> raw (filter pos?) (into (sorted-set)) vec)]
+ (validate-slots slots n)
+ slots)))
+
+;; ---------------------------------------------------------------------------
+;; Slicer: schedule + dataset → ordered steps (mirror slice_schedule; the tail
+;; after the last cut is intentionally NOT a step — include "end" to close it).
+;; ---------------------------------------------------------------------------
+
+(defn slice-schedule
+ [votes slots]
+ (loop [prev 0 [cut & more] slots i 0 acc []]
+ (if (nil? cut)
+ acc
+ (recur cut more (inc i)
+ (conj acc {:index i
+ :prev-slot prev
+ :cut-slot cut
+ :votes (subvec votes prev cut) ; (prev, cut] 0-based
+ :cut-time-ms (:t-ms (nth votes (dec cut)))})))))
+
+;; ---------------------------------------------------------------------------
+;; Feeding conv-update: FLIP the export sign to raw-DB (design §5).
+;; ---------------------------------------------------------------------------
+
+(defn ->conv-votes
+ [batch]
+ (mapv (fn [{:keys [pid tid sign t-ms]}]
+ {:pid pid :tid tid :vote (- (long sign)) :created t-ms})
+ batch))
+
+;; ---------------------------------------------------------------------------
+;; One full replay pass: seed → reduce conv-update, keeping conv per step.
+;; ---------------------------------------------------------------------------
+
+(defn run-once
+ "Returns a vector of [step conv-after-update] pairs, one per cut slot.
+ The reduce threading the conv IS the implicit warm-start chain."
+ [zid meta-tids steps]
+ (let [seed (-> (conv/new-conv) (assoc :zid zid :meta-tids (set meta-tids)))]
+ (loop [conv seed [s & more] steps acc []]
+ (if (nil? s)
+ acc
+ (let [conv' (conv/conv-update conv (->conv-votes (:votes s)))]
+ (recur conv' more (conj acc [s conv'])))))))
+
+;; ---------------------------------------------------------------------------
+;; Recording.
+;; ---------------------------------------------------------------------------
+
+(defn write-blob!
+ "Write the prep-main math_main view for one step, cheshire-encoded exactly as
+ postgres.clj pg-json does (the DB blob's own serialization)."
+ [dir step conv]
+ (spit (io/file dir (format "step-%03d.blob.json" (:index step)))
+ (json/generate-string (cm/prep-main conv))))
+
+(defn write-edn!
+ "Write full-fidelity conv state in the `conv-update-dump` shape
+ (conversation.clj:920), reloadable via `conv/load-conv-update`. The
+ core.matrix print-methods (conversation.clj:882-899) are already installed."
+ [dir step conv fed-votes]
+ (spit (io/file dir (format "step-%03d.edn" (:index step)))
+ (prn-str
+ {:conv (into {}
+ (assoc-in conv [:pca :center]
+ (matrix/matrix (into [] (:center (:pca conv))))))
+ :votes fed-votes
+ :opts {}
+ :error nil})))
+
+(defn write-results!
+ [dir results edn?]
+ (.mkdirs ^java.io.File dir)
+ (doseq [[s conv] results]
+ (write-blob! dir s conv)
+ (when edn? (write-edn! dir s conv (->conv-votes (:votes s))))))
+
+;; ---------------------------------------------------------------------------
+;; Provenance.
+;; ---------------------------------------------------------------------------
+
+(defn sha256-file
+ [path]
+ (let [md (MessageDigest/getInstance "SHA-256")
+ buf (byte-array 65536)]
+ (with-open [in (io/input-stream path)]
+ (loop []
+ (let [n (.read in buf)]
+ (when (pos? n) (.update md buf 0 n) (recur)))))
+ (->> (.digest md) (map #(format "%02x" (bit-and % 0xff))) (apply str))))
+
+(defn git-commit
+ [dir]
+ (try
+ (let [{:keys [exit out]} (shell/sh "git" "-C" (str dir) "rev-parse" "HEAD")]
+ (if (zero? exit) (str/trim out) "unknown"))
+ (catch Exception _ "unknown")))
+
+(defn build-provenance
+ [{:keys [schedule schedule-id source votes-path comments-path zid meta-tids
+ meta-tids-source warm-start repeats n-steps edn?]}]
+ {:engine "clj"
+ :mode "A"
+ :schedule_id schedule-id
+ :source source
+ :dataset (get schedule "dataset")
+ :zid zid
+ :n_steps n-steps
+ :repeats repeats
+ :edn edn?
+ :warm_start warm-start
+ :warm_start_note "chain is implicit: the reduce threads conv; :pca :comps seed the next step's start-vectors (conversation.clj:381-387)"
+ :vote_sign_convention "raw-db"
+ :vote_sign_note "export CSV signs (AGREE=+1) are FLIPPED to raw-DB (AGREE=-1) before conv-update; the flip is export-only (export.clj:106-113)"
+ :meta_tids (vec (sort meta-tids))
+ :meta_tids_source meta-tids-source
+ ;; Run from math/ (user.dir), which is inside the repo → HEAD is the math/
+ ;; commit (delphi and math share one repo in this checkout).
+ :math_git_commit (git-commit (System/getProperty "user.dir"))
+ :clojure_version (clojure-version)
+ :jvm_version (System/getProperty "java.version")
+ :jvm_runtime_version (System/getProperty "java.runtime.version")
+ :jvm_vm_name (System/getProperty "java.vm.name")
+ :matrix_implementation "vectorz"
+ :votes_file (.getName (io/file votes-path))
+ :votes_sha256 (sha256-file votes-path)
+ :comments_file (when comments-path (.getName (io/file comments-path)))
+ :comments_sha256 (when comments-path (sha256-file comments-path))
+ :created_at (str (Instant/now))})
+
+;; ---------------------------------------------------------------------------
+;; meta-tids from comments CSV (is-meta / is_meta column; else empty).
+;; ---------------------------------------------------------------------------
+
+(defn read-meta-tids
+ "Returns [meta-tid-set source-description]. Empty when no comments CSV or no
+ is-meta column (as for the public vw export)."
+ [comments-path]
+ (if (nil? comments-path)
+ [#{} "empty (no comments CSV supplied)"]
+ (with-open [rdr (io/reader comments-path)]
+ (let [rows (csv/read-csv rdr)
+ header (first rows)
+ idx (zipmap header (range))
+ ci (or (idx "comment-id") (idx "tid"))
+ mi (or (idx "is-meta") (idx "is_meta"))]
+ (if (or (nil? ci) (nil? mi))
+ [#{} (str "empty (comments CSV has no is-meta column; header=" (vec header) ")")]
+ [(->> (rest rows)
+ (keep (fn [r]
+ (let [v (str/lower-case (str/trim (str (nth r mi ""))))]
+ (when (#{"1" "true" "t" "yes"} v)
+ (Long/parseLong (str/trim (nth r ci)))))))
+ (into #{}))
+ "comments CSV is-meta column"])))))
+
+;; ---------------------------------------------------------------------------
+;; CLI.
+;; ---------------------------------------------------------------------------
+
+(def cli-options
+ [["-s" "--schedule PATH" "Path to the schedule JSON (§4)."]
+ ["-v" "--votes PATH" "Path to the export votes CSV."]
+ ["-o" "--out DIR" "Recording dir (…//); clj/ is written under it."]
+ [nil "--comments PATH" "Optional comments CSV (meta-tids via is-meta column)."]
+ [nil "--zid ZID" "Conversation id to seed (default: schedule dataset name)."]
+ ["-r" "--repeats N" "Full-replay repeats for §9 self-jitter (default 1)."
+ :default 1 :parse-fn #(Integer/parseInt %)]
+ [nil "--edn" "Also write per-step full-state EDN (conv-update-dump shape)."]
+ ["-h" "--help"]])
+
+(defn -main [& args]
+ (let [{:keys [options errors summary]} (cli/parse-opts args cli-options)]
+ (cond
+ (:help options)
+ (do (println "Replay harness — Clojure Mode A driver (Phase H-B)")
+ (println summary)
+ (System/exit 0))
+
+ errors
+ (do (binding [*out* *err*] (doseq [e errors] (println e)) (println summary))
+ (System/exit 1))
+
+ (some nil? [(:schedule options) (:votes options) (:out options)])
+ (do (binding [*out* *err*]
+ (println "ERROR: --schedule, --votes and --out are all required.")
+ (println summary))
+ (System/exit 1))
+
+ :else
+ (let [schedule (json/parse-string (slurp (:schedule options)))
+ dataset (get schedule "dataset")
+ schedule-id (get schedule "schedule_id")
+ source (get schedule "source" "votes-csv")
+ cuts (get schedule "cuts")
+ moderation (get schedule "moderation" "none")
+ warm-start (get-in schedule ["clojure" "warm_start"] "chain")
+ zid (or (:zid options) dataset)
+ repeats (:repeats options)
+ edn? (boolean (:edn options))
+ out (io/file (:out options))
+ clj-dir (io/file out "clj")]
+
+ (when-not (contains? #{"none" nil} moderation)
+ (throw (ex-info
+ (str "Moderation interleaving is NOT implemented in the Mode A "
+ "driver (the vw dataset has none). Got moderation="
+ (pr-str moderation) ". Use \"none\" or add mod-update interleaving.")
+ {:moderation moderation})))
+
+ ;; Only "chain" warm-start is implemented (it is IMPLICIT: the reduce
+ ;; threads the conv, whose :pca :comps seed the next step's start-vectors,
+ ;; conversation.clj:381-387). Reject any other value loudly rather than
+ ;; silently ignoring it — mirrors the moderation guard above.
+ (when-not (contains? #{"chain" nil} warm-start)
+ (throw (ex-info
+ (str "Only warm_start=\"chain\" is implemented in the Mode A "
+ "driver (chained warm start is implicit). Got warm_start="
+ (pr-str warm-start) ". Use \"chain\" (or omit it).")
+ {:warm_start warm-start})))
+
+ ;; Register cheshire encoders for core.matrix (mikera.*) — WITHOUT this,
+ ;; prep-main's :pca vectors fail to JSON-encode (postgres.clj relies on
+ ;; the same CoreMatrixBooter at system start).
+ (component/start
+ (cmb/create-core-matrix-booter {:config {:math {:matrix-implementation :vectorz}}}))
+
+ (let [raw (read-votes-csv (:votes options))
+ votes (build-dataset raw)
+ slots (resolve-cut-slots votes cuts)
+ steps (slice-schedule votes slots)
+ [meta-tids meta-src] (read-meta-tids (:comments options))]
+
+ (binding [*out* *err*]
+ (println (format "dataset=%s n_votes=%d schedule=%s cuts=%s"
+ dataset (count votes) schedule-id (pr-str slots)))
+ (println (format "steps=%d repeats=%d edn=%s zid=%s meta-tids=%d"
+ (count steps) repeats edn? (pr-str zid) (count meta-tids))))
+
+ (.mkdirs clj-dir)
+ ;; schedule.json verbatim (byte-faithful copy of the §4 input).
+ (io/copy (io/file (:schedule options)) (io/file out "schedule.json"))
+
+ ;; Run repeats. rep 0 is also written flat to clj/ (the canonical
+ ;; cross-language surface); rep i>0 (and rep 0) go to clj/rep-i/.
+ (dotimes [rep repeats]
+ (let [results (run-once zid meta-tids steps)
+ rep-dir (if (> repeats 1) (io/file clj-dir (str "rep-" rep)) clj-dir)]
+ (write-results! rep-dir results edn?)
+ (when (and (> repeats 1) (zero? rep))
+ (write-results! clj-dir results edn?))
+ (binding [*out* *err*]
+ (println (format " rep %d/%d written → %s" (inc rep) repeats (str rep-dir))))))
+
+ ;; Provenance (recording dir + a clj/ mirror so a later Python run's
+ ;; provenance.json cannot clobber ours).
+ (let [prov (build-provenance
+ {:schedule schedule :schedule-id schedule-id :source source
+ :votes-path (:votes options) :comments-path (:comments options)
+ :zid zid :meta-tids meta-tids :meta-tids-source meta-src
+ :warm-start warm-start :repeats repeats
+ :n-steps (count steps) :edn? edn?})
+ prov-json (json/generate-string prov {:pretty true})]
+ (spit (io/file out "provenance.json") prov-json)
+ (spit (io/file clj-dir "provenance.json") prov-json))
+
+ (println (format "wrote %d steps (x%d reps) → %s"
+ (count steps) repeats (str clj-dir)))
+ (System/exit 0))))))
diff --git a/math/dev/replay_smoke.sh b/math/dev/replay_smoke.sh
new file mode 100755
index 0000000000..7ebac82641
--- /dev/null
+++ b/math/dev/replay_smoke.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+# Replay harness Phase H-B — Clojure Mode A driver smoke test.
+#
+# Runs dev/replay.clj on the public vw dataset with a 3-cut vote-count schedule
+# and asserts the final step has non-empty base-clusters and the math_main
+# key shape. Optionally runs the Python driver on the SAME schedule into the
+# same recording dir for a cross-language gap measurement (needs the delphi
+# venv). No Docker, no Postgres.
+#
+# Usage (from math/): bash dev/replay_smoke.sh [--xlang]
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # math/
+DELPHI="$(cd "$HERE/../delphi" && pwd)"
+VW_DIR="$(ls -d "$DELPHI"/real_data/*-vw 2>/dev/null | head -1)"
+VOTES="$(ls "$VW_DIR"/*-votes.csv | head -1)"
+OUT="$DELPHI/real_data/.local/replays/vw/hb-3cut"
+
+SCHED="$(mktemp -t hb-3cut-XXXX.json)"
+cat > "$SCHED" <<'JSON'
+{
+ "dataset": "vw",
+ "schedule_id": "hb-3cut",
+ "source": "votes-csv",
+ "cuts": {"mode": "vote-count", "at": [1000, 2500, "end"]},
+ "moderation": "none",
+ "clojure": {"warm_start": "chain"},
+ "notes": "H-B smoke: 3-cut vote-count schedule on vw"
+}
+JSON
+
+echo ">> Clojure driver → $OUT/clj"
+rm -rf "$OUT"
+( cd "$HERE" && clojure -M:replay --schedule "$SCHED" --votes "$VOTES" --out "$OUT" --edn ) \
+ 2>&1 | grep -Ev '^(WARNING|Warning)|DEBUG|INFO \[polismath' || true
+
+echo ">> Asserting final-step blob"
+( cd "$DELPHI" && uv run python - "$OUT" ) <<'PY'
+import json, sys, glob, os
+out = sys.argv[1]
+steps = sorted(glob.glob(os.path.join(out, "clj", "step-*.blob.json")))
+assert steps, "no clj step blobs written"
+final = json.load(open(steps[-1]))
+bc = final["base-clusters"]["id"]
+assert len(bc) > 0, "final step has empty base-clusters"
+expected = {"base-clusters","group-clusters","subgroup-clusters","in-conv","mod-out",
+ "mod-in","meta-tids","lastVoteTimestamp","lastModTimestamp","n","n-cmts",
+ "pca","repness","group-aware-consensus","consensus","zid","tids",
+ "user-vote-counts","votes-base","group-votes","subgroup-votes",
+ "subgroup-repness","comment-priorities"}
+assert set(final.keys()) == expected, f"blob key shape drift: {set(final.keys()) ^ expected}"
+print(f" OK: {len(steps)} steps, final base-clusters={len(bc)}, 23-key math_main shape")
+PY
+
+if [[ "${1:-}" == "--xlang" ]]; then
+ echo ">> Python driver → $OUT/py (same schedule)"
+ ( cd "$DELPHI" && OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 \
+ uv run python scripts/replay_driver.py run --schedule "$SCHED" \
+ --out real_data/.local/replays ) 2>&1 | tail -2
+ echo ">> Cross-language compare (clj vs py)"
+ ( cd "$DELPHI" && OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 uv run python - "$OUT" <<'PY'
+import sys, tempfile
+sys.path.insert(0, "tests/replay_harness")
+from clj_crosslang import compare_clj_vs_py
+from polismath.replay import stepcompare as sc
+with tempfile.TemporaryDirectory() as shim:
+ print(sc.format_report(compare_clj_vs_py(sys.argv[1], shim_root=shim)))
+PY
+ )
+fi
+
+rm -f "$SCHED"
+echo ">> smoke OK"