Skip to content

python-math #9: feat(math): Clojure-exact base-cluster lineage + warm start (PR-C) - #2622

Draft
jucor wants to merge 1 commit into
spr/edge/2b7f93f6from
spr/edge/ca2af3c7
Draft

python-math #9: feat(math): Clojure-exact base-cluster lineage + warm start (PR-C)#2622
jucor wants to merge 1 commit into
spr/edge/2b7f93f6from
spr/edge/ca2af3c7

Conversation

@jucor

@jucor jucor commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

What

Ports the warm-start k-means that the Clojure engine threads across conversation-update ticks (:last-clusters, clusters.clj:301-312 / conversation.clj:403-445) into a new module polismath/pca_kmeans_rep/legacy_kmeans.py, wired into the clojure-legacy engine mode (the setting that reproduces the old Clojure engine's behavior exactly) of Conversation._compute_clusters. Improved mode (the default) is byte-for-byte unchanged.

Lineage semantics (all cited to math/src/polismath/math/clusters.clj)

  • Cold init = first-k-distinct rows, ids 0..k-1 (init-clusters, :55-65), reusing the production _get_first_k_distinct_centers so cold init is identical to kmeans_sklearn's first-k init.
  • clean-start-clusters (:230-277): safe-recenter drop-vanished + fallback (:171-191), uniqify identical centers (:220-227), most-distal split loop with new ids = (inc max id) (:202-217, :267).
  • Merge keeps the LARGER cluster's id, tie -> later argument (merge-clusters, :194-199).
  • cluster-step drops empty clusters, ties -> later cluster (:142-158).
  • same-clustering sorts centers and zip-TRUNCATES to the shorter list (utils/zip), reproduced deliberately (:68-76).

Wiring (conversation.py)

  • Base level: legacy_kmeans warm-started from prev.base_clusters (base-iters = 100). Base-cluster ids are stable across ticks; new participants get strictly larger ids.
  • Group level: per-k legacy_kmeans over base-cluster CENTERS, weighted by base-cluster member counts (:weights base-clusters-weights, conversation.clj:444), warm-started from the previous per-k clusterings. Clojure passes the MISNAMED :cluster-iters key that kmeans ignores, so the group level runs kmeans' DEFAULT max-iters (20), not group-iters (100) — matched here.
  • self.group_clusterings now holds id-carrying cluster dicts {id, members, center} in legacy mode (it was the (labels, centers, member_lists, silhouette) tuple), so the next tick can warm-start from it. Improved mode never writes it (stays {}).
  • recompute() threads prev_base_clusters alongside the existing prev_* state.

Cold-start invariance (HARD GATE)

MEASURED on the small vw test conversation dataset (67 in-conversation participants -> 67 singleton base clusters; groups k=2..5): legacy cold clustering is STRUCTURALLY bit-identical to improved at BOTH levels — same base/group memberships, ids, counts, and all downstream repness (representativeness stats), priorities, and group-votes. Cluster CENTER coordinates differ only at floating point (~1e-13: the ported weighted mean via np.average vs sklearn's centroid on identical memberships). The existing test_engine_mode cold-identity test is adjusted to compare numbers with a 1e-6 tolerance and everything else exactly, plus a new belt-and-braces test that drops center coordinates and asserts EXACT structural equality.

On near-duplicate projections (a degenerate synthetic set), legacy and improved DIVERGE at the base level too: legacy keeps each near-duplicate as its own exact-init singleton (Clojure-faithful) while sklearn's Lloyd collapses a pair and leaves an empty cluster. Base cold identity therefore holds only when projections are distinct (as on vw), and legacy is the more Clojure-faithful side.

Testing

RED evidence (before this commit): tests/test_legacy_kmeans.py — ModuleNotFoundError (module absent); tests/test_base_cluster_lineage.py warm-start-threading tests — legacy_kmeans never called (0 calls), group_clusterings stored a tuple rather than id-carrying dicts, and a new participant received id 0 (no lineage) instead of a larger id.

GREEN: 22 unit tests (hand-derived from the Clojure rules) + 8 integration tests (incl. vw cross-mode cold identity) pass; full suite 511 passed / 17 skipped / 47 xfailed (was 480/17/47), zero regressions.

Not ported

The agg-bucket-votes unknown-pid edge case (conv_edge_cases_test.clj:110-127) is not applicable — Python base-cluster members are always the current in-conversation participant ids (a subset of rating_mat.index) and _compute_group_votes already skips unknown pids defensively (conversation.py:1481-1486).

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

commit-id:ca2af3c7


Stack:


⚠️ Part of a stack created by spr. Do not merge manually using the UI - doing so may have unexpected results.

@jucor
jucor marked this pull request as draft July 18, 2026 02:29
@jucor
jucor force-pushed the spr/edge/2b7f93f6 branch from ec7b7a2 to b44a080 Compare July 18, 2026 13:28
@jucor
jucor force-pushed the spr/edge/ca2af3c7 branch from 99c120b to 380f97a Compare July 18, 2026 13:28
@jucor
jucor force-pushed the spr/edge/2b7f93f6 branch from b44a080 to 98736b7 Compare July 18, 2026 13:32
@jucor
jucor force-pushed the spr/edge/ca2af3c7 branch 2 times, most recently from 6a3085f to eb3dcb5 Compare July 18, 2026 13:35
@jucor
jucor requested a review from Copilot July 21, 2026 08:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Clojure-faithful, lineage-preserving k-means implementation for the clojure-legacy engine mode and wires it into the Delphi Conversation clustering pipeline, enabling warm-start clustering across ticks while keeping the default improved path unchanged.

Changes:

  • Introduces legacy_kmeans.py, a Python port of Clojure’s clusters.clj k-means semantics (lineage IDs, clean-start behavior, weighted recentering).
  • Wires legacy warm-start clustering into Conversation._compute_clusters (base + group levels) and threads previous-tick base/group clustering state via recompute().
  • Adds/updates unit + integration tests to validate lineage behavior and cold-start invariance (with float-tolerant center comparisons).

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
delphi/polismath/pca_kmeans_rep/legacy_kmeans.py New Clojure-faithful k-means + lineage implementation used in legacy engine mode.
delphi/polismath/conversation/conversation.py Integrates legacy k-means warm-start at base/group levels; threads previous tick state through recompute.
delphi/tests/test_legacy_kmeans.py Unit tests covering the ported Clojure k-means rules and lineage semantics.
delphi/tests/test_base_cluster_lineage.py Integration tests validating warm-start threading and base-cluster id lineage across ticks.
delphi/tests/test_engine_mode.py Updates cold-start invariance checks to allow float noise in cluster centers; adds structural equality check.
delphi/tests/test_group_k_smoother.py Adds tests for legacy smoother behavior on degenerate ticks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread delphi/tests/test_engine_mode.py
Comment thread delphi/tests/test_legacy_kmeans.py Outdated
Comment thread delphi/polismath/conversation/conversation.py
@jucor
jucor force-pushed the spr/edge/2b7f93f6 branch from 0eea394 to fd4aa0d Compare July 21, 2026 11:11
@jucor
jucor force-pushed the spr/edge/ca2af3c7 branch from eb3dcb5 to 45d5fca Compare July 21, 2026 11:11
@jucor
jucor force-pushed the spr/edge/2b7f93f6 branch from fd4aa0d to 4f39d5e Compare July 21, 2026 19:49
@jucor
jucor force-pushed the spr/edge/ca2af3c7 branch from 45d5fca to 46286fc Compare July 21, 2026 19:49
This was referenced Jul 25, 2026
@jucor
jucor force-pushed the spr/edge/2b7f93f6 branch from 40f4d62 to 8fda604 Compare July 28, 2026 00:11
@jucor
jucor force-pushed the spr/edge/ca2af3c7 branch from 05487ba to 37b9be8 Compare July 28, 2026 00:11
@jucor jucor changed the title feat(math): Clojure-exact base-cluster lineage + warm start (PR-C) python-math #9: feat(math): Clojure-exact base-cluster lineage + warm start (PR-C) Jul 28, 2026
… start (PR-C)

## What

Ports the warm-start k-means that the Clojure engine threads across conversation-update ticks (`:last-clusters`, `clusters.clj:301-312` / `conversation.clj:403-445`) into a new module `polismath/pca_kmeans_rep/legacy_kmeans.py`, wired into the `clojure-legacy` engine mode (the setting that reproduces the old Clojure engine's behavior exactly) of `Conversation._compute_clusters`. Improved mode (the default) is byte-for-byte unchanged.

## Lineage semantics (all cited to `math/src/polismath/math/clusters.clj`)

- Cold init = first-k-distinct rows, ids `0..k-1` (`init-clusters`, `:55-65`), reusing the production `_get_first_k_distinct_centers` so cold init is identical to `kmeans_sklearn`'s first-k init.
- `clean-start-clusters` (`:230-277`): safe-recenter drop-vanished + fallback (`:171-191`), uniqify identical centers (`:220-227`), most-distal split loop with new ids = `(inc max id)` (`:202-217, :267`).
- Merge keeps the LARGER cluster's id, tie -> later argument (`merge-clusters`, `:194-199`).
- `cluster-step` drops empty clusters, ties -> later cluster (`:142-158`).
- `same-clustering` sorts centers and zip-TRUNCATES to the shorter list (`utils/zip`), reproduced deliberately (`:68-76`).

## Wiring (`conversation.py`)

- Base level: `legacy_kmeans` warm-started from `prev.base_clusters` (base-iters = 100). Base-cluster ids are stable across ticks; new participants get strictly larger ids.
- Group level: per-k `legacy_kmeans` over base-cluster CENTERS, weighted by base-cluster member counts (`:weights base-clusters-weights`, `conversation.clj:444`), warm-started from the previous per-k clusterings. Clojure passes the MISNAMED `:cluster-iters` key that kmeans ignores, so the group level runs kmeans' DEFAULT max-iters (20), not group-iters (100) — matched here.
- `self.group_clusterings` now holds id-carrying cluster dicts `{id, members, center}` in legacy mode (it was the `(labels, centers, member_lists, silhouette)` tuple), so the next tick can warm-start from it. Improved mode never writes it (stays `{}`).
- `recompute()` threads `prev_base_clusters` alongside the existing `prev_*` state.

## Cold-start invariance (HARD GATE)

MEASURED on the small `vw` test conversation dataset (67 in-conversation participants -> 67 singleton base clusters; groups k=2..5): legacy cold clustering is STRUCTURALLY bit-identical to improved at BOTH levels — same base/group memberships, ids, counts, and all downstream repness (representativeness stats), priorities, and group-votes. Cluster CENTER coordinates differ only at floating point (~1e-13: the ported weighted mean via `np.average` vs sklearn's centroid on identical memberships). The existing `test_engine_mode` cold-identity test is adjusted to compare numbers with a 1e-6 tolerance and everything else exactly, plus a new belt-and-braces test that drops center coordinates and asserts EXACT structural equality.

On near-duplicate projections (a degenerate synthetic set), legacy and improved DIVERGE at the base level too: legacy keeps each near-duplicate as its own exact-init singleton (Clojure-faithful) while sklearn's Lloyd collapses a pair and leaves an empty cluster. Base cold identity therefore holds only when projections are distinct (as on `vw`), and legacy is the more Clojure-faithful side.

## Testing

RED evidence (before this commit): `tests/test_legacy_kmeans.py` — ModuleNotFoundError (module absent); `tests/test_base_cluster_lineage.py` warm-start-threading tests — `legacy_kmeans` never called (0 calls), `group_clusterings` stored a tuple rather than id-carrying dicts, and a new participant received id 0 (no lineage) instead of a larger id.

GREEN: 22 unit tests (hand-derived from the Clojure rules) + 8 integration tests (incl. `vw` cross-mode cold identity) pass; full suite 511 passed / 17 skipped / 47 xfailed (was 480/17/47), zero regressions.

## Not ported

The agg-bucket-votes unknown-pid edge case (`conv_edge_cases_test.clj:110-127`) is not applicable — Python base-cluster members are always the current in-conversation participant ids (a subset of `rating_mat.index`) and `_compute_group_votes` already skips unknown pids defensively (`conversation.py:1481-1486`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

commit-id:ca2af3c7
@jucor
jucor force-pushed the spr/edge/ca2af3c7 branch from 37b9be8 to 5f34d56 Compare July 28, 2026 01:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants