Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 16 additions & 44 deletions delphi/polismath/conversation/conversation.py
Original file line number Diff line number Diff line change
Expand Up @@ -737,11 +737,11 @@ def _compute_pca(self, n_components: int = 2,
# Check if we have enough data. Clojure runs the REAL math on any
# non-empty matrix — a 1x1 single-vote conversation yields center =
# the vote and a rank-capped zero component (every-vote step-0
# oracle, journal 2026-07-22) — so clojure-legacy only short-circuits
# on a truly EMPTY dimension; improved keeps the <2 guard.
tiny = self.rating_mat.shape[0] < 2 or self.rating_mat.shape[1] < 2
# oracle, journal 2026-07-22) — so only a truly EMPTY dimension
# short-circuits. (The former improved-mode <2 guard is parked:
# POST_CUTOVER_IMPROVEMENTS.md item 2.)
empty = self.rating_mat.shape[0] == 0 or self.rating_mat.shape[1] == 0
if empty or (tiny and resolve_engine_mode() != ENGINE_MODE_LEGACY):
if empty:
# Not enough data for PCA, create minimal results
cols = max(self.rating_mat.shape[1], 1)
self.pca = {
Expand Down Expand Up @@ -907,25 +907,16 @@ def _compute_clusters(self,
# Degenerate-tick port (journal 2026-07-21 verdict): Clojure has NO
# <2-participants guard past the truly-empty short-circuit — with one
# in-conv participant its graph still runs the full base->group chain
# (one base cluster, k=2 group clustering of one point). Legacy mode
# falls through and replicates that; improved mode keeps the guard.
# The 0-participant early return stays in BOTH modes, but the
# greedy-floor unreachability argument (in-conv can't drop below 1) is
# LEGACY-only — the greedy floor itself only runs in 'clojure-legacy'
# mode (_get_in_conv_participants). In 'improved' mode there is no
# floor, so 0 in-conv participants is a REAL, load-bearing case this
# guard must handle, not just dead code.
if len(in_conv_pids_list) == 0 or (not legacy_mode and len(in_conv_pids_list) < 2):
# (one base cluster, k=2 group clustering of one point). The greedy
# floor (_get_in_conv_participants) keeps in-conv from dropping below
# 1 on warm ticks, so this 0-participant early return covers the
# cold/empty case only. (The former improved-mode <2 guard is parked:
# POST_CUTOVER_IMPROVEMENTS.md item 2.)
if len(in_conv_pids_list) == 0:
logger.warning(f"Not enough participants meeting threshold ({len(in_conv_pids_list)})")
self.base_clusters = []
self.group_clusters = []
self.subgroup_clusters = {}
if not legacy_mode:
# Improved mode has no warm-start use for this state, so a
# stale value from a prior tick must not leak forward
# (#2642 review finding).
self.group_clusterings = {}
self.group_k_smoother = {}
return

logger.info(f"Using {len(in_conv_pids_list)}/{len(self.proj)} participants for clustering")
Expand Down Expand Up @@ -996,31 +987,12 @@ def _compute_clusters(self,
# clusters at the distinct-point count -> one cluster, lineage id
# preserved), stores the fresh 1-cluster :group-clusterings, and the
# next tick warm-starts from it — recovery splits mint ids via
# (inc (apply max ids)) (clusters.clj:267). Legacy mode falls through
# to the normal per-k loop below, which reproduces all of that
# (max_k arithmetic yields range [2]; silhouette of a singleton
# clustering is 0.0, matching Clojure's singleton rule
# clusters.clj:350-353, so the smoother advance is unchanged from
# P6a). Improved mode keeps the early return byte-for-byte.
if not legacy_mode and len(base_clusters) < 2:
logger.warning(f"Not enough base clusters for group clustering ({len(base_clusters)})")
self.base_clusters = base_clusters
# Maintain consistent group-cluster schema: members are base-cluster IDs
if len(base_clusters) == 1:
self.group_clusters = [{
'id': 0,
'center': base_clusters[0]['center'],
'members': [base_clusters[0]['id']],
}]
else:
self.group_clusters = []
self.subgroup_clusters = {}
# Improved-only path (already gated by `not legacy_mode` above): no
# warm-start use for this state, so a stale value from a prior
# tick must not leak forward (#2642 review finding).
self.group_clusterings = {}
self.group_k_smoother = {}
return
# (inc (apply max ids)) (clusters.clj:267). We fall through to the
# normal per-k loop below, which reproduces all of that (max_k
# arithmetic yields range [2]; silhouette of a singleton clustering
# is 0.0, matching Clojure's singleton rule clusters.clj:350-353, so
# the smoother advance is unchanged from P6a). (The former improved-
# mode <2 early return is parked: POST_CUTOVER_IMPROVEMENTS.md item 2.)

# Prepare base cluster centers and weights
base_centers_array = np.array([c['center'] for c in base_clusters])
Expand Down
15 changes: 6 additions & 9 deletions delphi/polismath/pca_kmeans_rep/repness.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,15 +848,12 @@ def conv_repness(vote_matrix_df: pd.DataFrame,
'comment_repness': []
}

# Check if we have enough data. Clojure computes repness/consensus for
# ANY matrix (its best-agree guarantee produces an entry even for a
# single-vote 1x1 conversation; rest-stats over zero other groups fall
# back to the (0+1)/(0+2) prior — every-vote step-0 oracle, journal
# 2026-07-22). clojure-legacy therefore proceeds; improved keeps the
# historical <2 guard.
if vote_matrix_df.shape[0] < 2 or vote_matrix_df.shape[1] < 2:
if resolve_engine_mode() != ENGINE_MODE_LEGACY:
return empty_result
# Clojure computes repness/consensus for ANY matrix (its best-agree
# guarantee produces an entry even for a single-vote 1x1 conversation;
# rest-stats over zero other groups fall back to the (0+1)/(0+2) prior —
# every-vote step-0 oracle, journal 2026-07-22), so no size guard here.
# (The former improved-mode <2 guard is parked:
# POST_CUTOVER_IMPROVEMENTS.md item 2.)

# Convert wide-format to long-format DataFrame
# Wide: participants × comments (values = votes)
Expand Down
64 changes: 5 additions & 59 deletions delphi/tests/test_degenerate_tick_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
recovery splits mint ids via `(inc (apply max ids))` (clean-start-clusters,
clusters.clj:267).

Python legacy mode used to early-return on both edges, keeping the last
NON-degenerate group_clusterings as the warm seed — different seeds, different
cluster ids across a degenerate episode. These tests pin the Clojure semantics
in 'clojure-legacy' mode and pin that 'improved' mode keeps its guards
byte-for-byte.
Python used to early-return on both edges, keeping the last NON-degenerate
group_clusterings as the warm seed — different seeds, different cluster ids
across a degenerate episode. These tests pin the Clojure semantics. (The
former improved-mode guards and their tests are parked:
POST_CUTOVER_IMPROVEMENTS.md item 2, mode collapse 2026-07-27.)
"""

import os
Expand Down Expand Up @@ -77,12 +77,6 @@ def legacy_mode(monkeypatch):
monkeypatch.setenv(ENGINE_MODE_ENV_VAR, 'clojure-legacy')


@pytest.fixture
def improved_mode(monkeypatch):
monkeypatch.delenv(PCA_IMPL_ENV_VAR, raising=False)
monkeypatch.setenv(ENGINE_MODE_ENV_VAR, 'improved')


# ---------------------------------------------------------------------------
# Legacy mode: degenerate tick recomputes and threads group_clusterings
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -160,53 +154,5 @@ def test_single_participant_runs_full_chain(self, legacy_mode):
assert conv.group_k_smoother.get('last_k_count') == 1


# ---------------------------------------------------------------------------
# Improved mode: both guards keep their existing behavior byte-for-byte
# ---------------------------------------------------------------------------

class TestImprovedGuardsUnchanged:

def test_single_participant_early_return(self, improved_mode):
conv = Conversation('solo').update_votes(_single_ptpt_votes())
assert conv.base_clusters == []
assert conv.group_clusters == []
assert conv.group_clusterings == {}
assert conv.group_k_smoother == {}

def test_degenerate_tick_keeps_synthesized_cluster(self, improved_mode):
conv = Conversation('deg').update_votes(_two_bloc_votes())
conv = conv.update_votes(_collapse_votes())
assert len(conv.base_clusters) == 1
[base] = conv.base_clusters
# Improved keeps the synthesized id-0 wrapper and stateless smoother.
assert conv.group_clusters == [{
'id': 0,
'center': base['center'],
'members': [base['id']],
}]
assert conv.group_clusterings == {}
assert conv.group_k_smoother == {}


class TestImprovedStaleStateReset:
"""#2642 review finding: the <2-in-conv-participants early return resets
base_clusters/group_clusters/subgroup_clusters but used to leave
group_clusterings/group_k_smoother untouched. In 'improved' mode there is
no warm-start use for that state (unlike 'clojure-legacy'), so a stale
value set before a guarded tick would otherwise leak forward into the
result unchanged instead of being reset to {}."""

def test_single_participant_resets_stale_group_state(self, improved_mode):
conv = Conversation('solo')
conv.group_clusterings = {2: "SENTINEL"}
conv.group_k_smoother = {"k": 1}

result = conv.update_votes(_single_ptpt_votes())

assert result.base_clusters == [], "sanity: must hit the early-return path"
assert result.group_clusterings == {}
assert result.group_k_smoother == {}


if __name__ == '__main__':
pytest.main([__file__, '-v'])
Loading