From 25dcc93ee2ea3b1db9c7f8224c9680ef6940373a Mon Sep 17 00:00:00 2001 From: Julien Cornebise Date: Mon, 27 Jul 2026 17:41:35 +0200 Subject: [PATCH] =?UTF-8?q?python-math=20#39:=20feat(math):=20mode=20colla?= =?UTF-8?q?pse=20C3=20=E2=80=94=20powerit=20warm-start=20PCA=20+=20legacy?= =?UTF-8?q?=20kmeans=20as=20the=20only=20solvers=20(item=208=20parked)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What `GOAL_CUTOVER_READY.md` Phase 2, chunk C3 of the mode collapse (removing the engine's improved-mode branches so the Clojure-faithful behavior is the only code path). The engine's solver paths are now unconditionally the Clojure-faithful ones: - `_compute_pca`: always warm-starts power iteration from the previous tick's components (Clojure's `:start-vectors`, `conversation.clj:385`), with `require_powerit=True`. - `_compute_clusters`: the base level always runs `legacy_kmeans` with lineage warm start (PR-C of this stack); the group level always runs the per-k legacy loop with the group-K smoother (PR-D). The sklearn cold-recompute arms (`kmeans_sklearn` at the base level + `best_k` group selection) are deleted; dead `GROUP_ITERS`/`base_weights`/`legacy_mode` cleaned up. - `POLISMATH_PCA_IMPL` (the env var selecting the PCA implementation) is left in `pca.py` but is now ENGINE-INERT: with `require_powerit` always True, a sklearn selection is always overridden back to powerit (the existing warn+fallback). Full removal ships with queue item 8 post-cutover — deleting it now would cascade through 9 test files for zero behavior change (scope ruling, journal session 7). ## Tests Improved-mode pins deleted with their branches: improved-tick2-cold, cross-mode cold-tick equality (PCA warm start), improved-never-calls-legacy-kmeans + cross-mode vw invariance (lineage; replaced by a legacy determinism pin), and 3 smoother-inert tests. 51 tests green across the affected files. The deleted sklearn arms are queue item 8 in `POST_CUTOVER_IMPROVEMENTS.md` (park commit at the end of the collapse). commit-id:30038853 --- delphi/polismath/conversation/conversation.py | 292 ++++++------------ delphi/tests/test_base_cluster_lineage.py | 41 ++- delphi/tests/test_group_k_smoother.py | 39 +-- delphi/tests/test_pca_warm_start.py | 19 +- 4 files changed, 127 insertions(+), 264 deletions(-) diff --git a/delphi/polismath/conversation/conversation.py b/delphi/polismath/conversation/conversation.py index 19f58c989..d06ec416c 100644 --- a/delphi/polismath/conversation/conversation.py +++ b/delphi/polismath/conversation/conversation.py @@ -21,7 +21,6 @@ compute_comment_extremity, ) from polismath.pca_kmeans_rep.clusters import ( - kmeans_sklearn, calculate_silhouette_sklearn ) from polismath.pca_kmeans_rep.repness import conv_repness @@ -63,9 +62,9 @@ def _labels_from_id_clusters(row_names: List[Any], """Label array aligned with ``row_names`` for silhouette scoring: the index (in ``clusters``) of the cluster that contains each name. - Used at the group level to score a legacy (id-carrying) clustering with the - same ``calculate_silhouette_sklearn`` the improved path uses, so the smoother - sees comparable silhouettes. Every base cluster is assigned to exactly one + Used at the group level to score a legacy (id-carrying) clustering with + ``calculate_silhouette_sklearn``, so the smoother sees comparable + silhouettes. Every base cluster is assigned to exactly one group cluster by ``cluster-step``; a name that (degenerately) appears in none gets its own singleton label so it never silently merges into label 0. """ @@ -756,30 +755,25 @@ def _compute_pca(self, n_components: int = 2, # Make a clean copy of the rating matrix clean_matrix = self._get_clean_matrix() - # Engine-mode warm start (PR-B). In 'clojure-legacy' mode we thread - # the previous tick's unit components back in as the power-iteration - # start vectors (Clojure :start-vectors, conversation.clj:385) and - # require the power-iteration solver (sklearn cannot inject start - # vectors). In the default 'improved' mode nothing changes: - # start_vectors stays None and the solver is chosen purely by - # POLISMATH_PCA_IMPL, so this call is byte-identical to the pre-PR - # behavior. + # Warm start (PR-B): thread the previous tick's unit components + # back in as the power-iteration start vectors (Clojure + # :start-vectors, conversation.clj:385) and require the + # power-iteration solver (sklearn cannot inject start vectors — + # the former improved-mode sklearn path is parked: + # POST_CUTOVER_IMPROVEMENTS.md item 8). start_vectors = None - require_powerit = False - if resolve_engine_mode() == ENGINE_MODE_LEGACY: - require_powerit = True - if prev_pca is not None and prev_pca.get('comps') is not None: - # Only warm-start from real components; a missing/None - # 'comps' (np.asarray(None) would be a size-1 object array, - # a garbage seed) or empty/cold state (first tick) falls - # through to the cold random draw. - prev_comps = np.asarray(prev_pca['comps']) - if prev_comps.size > 0: - start_vectors = prev_comps + if prev_pca is not None and prev_pca.get('comps') is not None: + # Only warm-start from real components; a missing/None + # 'comps' (np.asarray(None) would be a size-1 object array, + # a garbage seed) or empty/cold state (first tick) falls + # through to the cold random draw. + prev_comps = np.asarray(prev_pca['comps']) + if prev_comps.size > 0: + start_vectors = prev_comps pca_results, proj_dict = pca_project_dataframe( clean_matrix, n_components, - start_vectors=start_vectors, require_powerit=require_powerit) + start_vectors=start_vectors, require_powerit=True) # Store results self.pca = pca_results @@ -878,9 +872,8 @@ def _compute_clusters(self, BASE_K = 100 MAX_K = 5 BASE_ITERS = 100 # Clojure :base-iters (conversation.clj:147) - GROUP_ITERS = 100 # improved-mode group iterations (unchanged) - # Legacy-mode group iterations: Clojure passes :cluster-iters — a key - # kmeans IGNORES — so the group level runs kmeans' DEFAULT max-iters of + # Group iterations: Clojure passes :cluster-iters — a key kmeans + # IGNORES — so the group level runs kmeans' DEFAULT max-iters of # 20, not :group-iters (clusters.clj:303, conversation.clj:443). GROUP_LEGACY_ITERS = 20 @@ -902,8 +895,6 @@ def _compute_clusters(self, # Filter projections to only include in-conv participants in_conv_pids_list = [pid for pid in self.proj.keys() if pid in in_conv_pids] - legacy_mode = resolve_engine_mode() == ENGINE_MODE_LEGACY - # 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 @@ -930,50 +921,28 @@ def _compute_clusters(self, actual_base_k = min(BASE_K, len(in_conv_pids_list)) logger.info(f"Computing base clusters with k={actual_base_k}...") - if legacy_mode: - # PR-C: base-level warm start with lineage. Clojure threads the prior - # tick's base clusters into k-means as :last-clusters - # (conversation.clj:403-410 -> clusters.clj:301-312 -> clean-start- - # clusters), so base-cluster ids are STABLE across ticks, new ids - # strictly increase, and merges keep the larger side's id. The ported - # legacy_kmeans keys clusters to the current data by member NAME - # (participant id), which is what lets prior members be recentered or - # dropped. base-iters = 100 (conversation.clj:147). - base_data = _LegacyNamedData(in_conv_pids_list, base_proj_values) - last_base = _base_clusters_to_legacy(prev_base_clusters) - legacy_base = legacy_kmeans( - base_data, actual_base_k, - last_clusters=last_base, weights=None, max_iters=BASE_ITERS) - legacy_base.sort(key=lambda c: c['id']) # Clojure sort-by :id (conversation.clj:406) - base_clusters = [ - {'id': c['id'], - 'center': np.asarray(c['center'], dtype=float).tolist(), - 'members': list(c['members'])} - for c in legacy_base - ] - else: - # Improved (default): cold recompute, byte-for-byte unchanged. - base_labels, base_centers, base_member_lists = kmeans_sklearn( - base_proj_values, - k=actual_base_k, - max_iters=BASE_ITERS - ) - - # Convert to dictionary format with participant IDs as members - base_clusters = [] - for cluster_id, (center, member_indices) in enumerate(zip(base_centers, base_member_lists)): - # Map indices back to participant IDs - member_pids = [in_conv_pids_list[idx] for idx in member_indices] - base_clusters.append({ - 'id': cluster_id, - 'center': center.tolist(), - 'members': member_pids - }) - - # Keep base clusters in k-means ID order (matching Clojure's sort-by :id) - # Do NOT sort by size or reassign IDs — that would change the encounter - # order of centers used in group clustering's first-k-distinct initialization. - base_clusters.sort(key=lambda c: c['id']) + # PR-C: base-level warm start with lineage. Clojure threads the prior + # tick's base clusters into k-means as :last-clusters + # (conversation.clj:403-410 -> clusters.clj:301-312 -> clean-start- + # clusters), so base-cluster ids are STABLE across ticks, new ids + # strictly increase, and merges keep the larger side's id. The ported + # legacy_kmeans keys clusters to the current data by member NAME + # (participant id), which is what lets prior members be recentered or + # dropped. base-iters = 100 (conversation.clj:147). (The former + # improved-mode sklearn cold recompute is parked: + # POST_CUTOVER_IMPROVEMENTS.md item 8.) + base_data = _LegacyNamedData(in_conv_pids_list, base_proj_values) + last_base = _base_clusters_to_legacy(prev_base_clusters) + legacy_base = legacy_kmeans( + base_data, actual_base_k, + last_clusters=last_base, weights=None, max_iters=BASE_ITERS) + legacy_base.sort(key=lambda c: c['id']) # Clojure sort-by :id (conversation.clj:406) + base_clusters = [ + {'id': c['id'], + 'center': np.asarray(c['center'], dtype=float).tolist(), + 'members': list(c['members'])} + for c in legacy_base + ] logger.info(f"Created {len(base_clusters)} base clusters") @@ -994,9 +963,8 @@ def _compute_clusters(self, # 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 + # Prepare base cluster centers (weights are keyed by id below) base_centers_array = np.array([c['center'] for c in base_clusters]) - base_weights = np.array([len(c['members']) for c in base_clusters]) # Calculate max_k for group clustering max_k = min(MAX_K, 2 + len(base_clusters) // 12) @@ -1004,122 +972,64 @@ def _compute_clusters(self, logger.info(f"Computing group clusters with k range 2-{max_k}...") - if legacy_mode: - # PR-C: group-level warm start with lineage + weighted recentering. - # Clojure clusters the BASE-CLUSTER CENTERS (base-clusters-proj), - # weighted by base-cluster member counts (:weights base-clusters- - # weights, conversation.clj:433-445), warm-starting each per-k - # clustering from the prior tick's k-clustering (:last-clusters - # (last-clusterings k), conversation.clj:441). - # - # Clojure passes :cluster-iters (a key kmeans does NOT destructure, - # clusters.clj:303), so the group level actually runs kmeans' DEFAULT - # max-iters (20), NOT :group-iters (100). We reproduce that - # (GROUP_LEGACY_ITERS below); well-separated data converges long - # before either bound, so on real conversations it is inert. - base_ids = [c['id'] for c in base_clusters] - base_weights_by_id = {c['id']: len(c['members']) for c in base_clusters} - group_data = _LegacyNamedData(base_ids, base_centers_array) - prev_gc = prev_group_clusterings or {} - - legacy_group_clusterings: Dict[int, List[Dict[str, Any]]] = {} - silhouettes_by_k: Dict[int, float] = {} - for k in range(2, max_k + 1): - gc = legacy_kmeans( - group_data, k, - last_clusters=prev_gc.get(k), - weights=base_weights_by_id, - max_iters=GROUP_LEGACY_ITERS) - gc.sort(key=lambda c: c['id']) # Clojure sort-by :id (conversation.clj:437) - legacy_group_clusterings[k] = gc - # Score with the SAME silhouette the improved path uses, on the - # legacy assignment, so the smoother sees comparable numbers. - labels = _labels_from_id_clusters(base_ids, gc) - score = calculate_silhouette_sklearn(base_centers_array, labels) - silhouettes_by_k[k] = score - logger.info(f" k={k}: silhouette={score:.4f}") - - # Group-K smoother (PR-D): damps K flicker (K only switches after - # :group-k-buffer=4 consecutive ticks agree) with Clojure's max-key - # HIGHER-k-wins tie-break, threading {last_k, last_k_count, - # smoothed_k}. self.group_clusterings holds the id-carrying cluster - # dicts (legacy value type) — the warm start read next tick. - new_smoother_state, selected_k = group_k_smoother_update( - prev_group_k_smoother or {}, silhouettes_by_k) - self.group_clusterings = legacy_group_clusterings - self.group_k_smoother = new_smoother_state - logger.info(f"Legacy group-k-smoother: smoothed_k={selected_k} " - f"state={new_smoother_state}") - - # Build production-form group_clusters from the selected clustering. - # Members are base-cluster ids; ids carry the group-cluster lineage. - selected = legacy_group_clusterings[selected_k] - group_clusters = [ - {'id': c['id'], - 'center': np.asarray(c['center'], dtype=float).tolist(), - 'members': list(c['members'])} - for c in selected - ] - group_clusters.sort(key=lambda c: c['id']) - else: - # Improved (default): cold recompute + best_k selection, byte-for-byte - # unchanged. Clear any legacy warm-start state a prior clojure-legacy - # tick may have left on this instance: improved mode is stateless - # across ticks (no stale memory retained after a mode switch), and a - # later switch back to legacy warm-starts cold — same as a fresh - # Clojure worker boot. No-op in pure improved runs (both init to {}). - self.group_clusterings = {} - self.group_k_smoother = {} - best_k = 2 - best_score = -1 - group_clusterings = {} - - for k in range(2, max_k + 1): - group_labels, group_centers, group_member_lists = kmeans_sklearn( - base_centers_array, - k=k, - max_iters=GROUP_ITERS, - weights=base_weights - ) - - # Calculate silhouette score - score = calculate_silhouette_sklearn(base_centers_array, group_labels) - group_clusterings[k] = (group_labels, group_centers, group_member_lists, score) - - logger.info(f" k={k}: silhouette={score:.4f}") - - if score > best_score: - best_score = score - best_k = k - - logger.info(f"Selected k={best_k} with silhouette={best_score:.4f}") - - selected_k = best_k - - # Use the selected clustering. group_clusters is never None. - group_labels, group_centers, group_member_lists, _ = group_clusterings[selected_k] - - # Convert to dictionary format with base cluster IDs as members - group_clusters = [] - for cluster_id, (center, member_indices) in enumerate(zip(group_centers, group_member_lists)): - # Members are base cluster IDs (not participant IDs!) - member_base_cluster_ids = [base_clusters[idx]['id'] for idx in member_indices] - group_clusters.append({ - 'id': cluster_id, - 'center': center.tolist(), - 'members': member_base_cluster_ids - }) - - # Keep group clusters in k-means ID order (matching Clojure's - # sort-by :id, conversation.clj:437). Do NOT sort by size or - # reassign IDs: Clojure assigns group ids by first-k-distinct - # encounter order over base-cluster centers (init-clusters, - # clusters.clj:55-64) and never re-orders by size. The former - # size-descending re-sort here was the root cause of the gid 0↔1 - # label swap vs Clojure blobs (S3-4 trace, 2026-06-11: identical - # memberships modulo label permutation on vw-cold_start). Mirrors - # the identical rule at the base-cluster level above. - group_clusters.sort(key=lambda c: c['id']) + # PR-C: group-level warm start with lineage + weighted recentering. + # Clojure clusters the BASE-CLUSTER CENTERS (base-clusters-proj), + # weighted by base-cluster member counts (:weights base-clusters- + # weights, conversation.clj:433-445), warm-starting each per-k + # clustering from the prior tick's k-clustering (:last-clusters + # (last-clusterings k), conversation.clj:441). (The former improved- + # mode sklearn cold recompute + best_k selection is parked: + # POST_CUTOVER_IMPROVEMENTS.md item 8.) + # + # Clojure passes :cluster-iters (a key kmeans does NOT destructure, + # clusters.clj:303), so the group level actually runs kmeans' DEFAULT + # max-iters (20), NOT :group-iters (100). We reproduce that + # (GROUP_LEGACY_ITERS below); well-separated data converges long + # before either bound, so on real conversations it is inert. + base_ids = [c['id'] for c in base_clusters] + base_weights_by_id = {c['id']: len(c['members']) for c in base_clusters} + group_data = _LegacyNamedData(base_ids, base_centers_array) + prev_gc = prev_group_clusterings or {} + + legacy_group_clusterings: Dict[int, List[Dict[str, Any]]] = {} + silhouettes_by_k: Dict[int, float] = {} + for k in range(2, max_k + 1): + gc = legacy_kmeans( + group_data, k, + last_clusters=prev_gc.get(k), + weights=base_weights_by_id, + max_iters=GROUP_LEGACY_ITERS) + gc.sort(key=lambda c: c['id']) # Clojure sort-by :id (conversation.clj:437) + legacy_group_clusterings[k] = gc + # Score with the silhouette on the legacy assignment, so the + # smoother sees comparable numbers. + labels = _labels_from_id_clusters(base_ids, gc) + score = calculate_silhouette_sklearn(base_centers_array, labels) + silhouettes_by_k[k] = score + logger.info(f" k={k}: silhouette={score:.4f}") + + # Group-K smoother (PR-D): damps K flicker (K only switches after + # :group-k-buffer=4 consecutive ticks agree) with Clojure's max-key + # HIGHER-k-wins tie-break, threading {last_k, last_k_count, + # smoothed_k}. self.group_clusterings holds the id-carrying cluster + # dicts — the warm start read next tick. + new_smoother_state, selected_k = group_k_smoother_update( + prev_group_k_smoother or {}, silhouettes_by_k) + self.group_clusterings = legacy_group_clusterings + self.group_k_smoother = new_smoother_state + logger.info(f"Legacy group-k-smoother: smoothed_k={selected_k} " + f"state={new_smoother_state}") + + # Build production-form group_clusters from the selected clustering. + # Members are base-cluster ids; ids carry the group-cluster lineage. + selected = legacy_group_clusterings[selected_k] + group_clusters = [ + {'id': c['id'], + 'center': np.asarray(c['center'], dtype=float).tolist(), + 'members': list(c['members'])} + for c in selected + ] + group_clusters.sort(key=lambda c: c['id']) logger.info(f"Created {len(group_clusters)} group clusters") diff --git a/delphi/tests/test_base_cluster_lineage.py b/delphi/tests/test_base_cluster_lineage.py index 6aa5dcd63..3218e031c 100644 --- a/delphi/tests/test_base_cluster_lineage.py +++ b/delphi/tests/test_base_cluster_lineage.py @@ -9,11 +9,12 @@ pre-PR Python legacy branch recomputed clusters COLD every tick (kmeans_sklearn with no warm start), so no lineage was threaded. This module verifies: - 1. Cold first tick: legacy base + group partitions equal improved mode - (the cold-start invariant — measured identical on vw and synthetic data). - 2. Warm-start threading: in legacy mode the ported legacy_kmeans is called - with the prior tick's clusters as last_clusters (base and per-k group); in - improved mode it is never called. + 1. Cold first tick: the base partition is Clojure-faithful up to Q11 merges, + and group clustering is deterministic run-to-run. + 2. Warm-start threading: the ported legacy_kmeans is called with the prior + tick's clusters as last_clusters (base and per-k group). (This is the + engine's only path since the mode collapse; the former improved-mode + sklearn cold recompute is parked: POST_CUTOVER_IMPROVEMENTS.md item 8.) 3. self.group_clusterings holds id-carrying cluster dicts (legacy value type), not the (labels, centers, member_lists, silhouette) tuple. 4. Base-cluster ids are stable across chained update_votes and new @@ -134,13 +135,6 @@ def _spy(self, monkeypatch): monkeypatch.setattr(conv_mod, 'legacy_kmeans', spy) return spy - def test_improved_never_calls_legacy_kmeans(self, monkeypatch): - monkeypatch.delenv(PCA_IMPL_ENV_VAR, raising=False) - monkeypatch.setenv(ENGINE_MODE_ENV_VAR, 'improved') - spy = self._spy(monkeypatch) - Conversation('x').update_votes(_many_ptpt_votes()) - assert spy.calls == [] - def test_legacy_base_warm_start_threaded(self, monkeypatch): monkeypatch.delenv(PCA_IMPL_ENV_VAR, raising=False) monkeypatch.setenv(ENGINE_MODE_ENV_VAR, 'clojure-legacy') @@ -224,26 +218,27 @@ def test_ids_stable_and_new_participant_gets_larger_id(self, monkeypatch): # 5. vw real-data cold-start invariance (base AND group), the documented gate. # --------------------------------------------------------------------------- -class TestVwColdStartInvariance: +class TestVwColdStartDeterminism: """On vw (67 in-conv participants -> 67 singleton base clusters; groups for - k=2..5), cold legacy clustering was measured bit-identical to improved mode - at BOTH levels. Locked in here; skips if the committed vw dataset is absent. - """ + k=2..5), cold clustering is deterministic run-to-run. (The former + cross-mode invariance assertion went with the mode collapse — the battery + now pins vw against the Clojure oracle directly, which is stronger.) + Skips if the committed vw dataset is absent.""" - def _vw_conv(self, monkeypatch, mode): + def _vw_conv(self, monkeypatch): try: from polismath.replay.real_data import load_export_votes ds = load_export_votes('vw') except (ImportError, FileNotFoundError): pytest.skip('vw dataset unavailable') monkeypatch.delenv(PCA_IMPL_ENV_VAR, raising=False) - monkeypatch.setenv(ENGINE_MODE_ENV_VAR, mode) + monkeypatch.setenv(ENGINE_MODE_ENV_VAR, 'clojure-legacy') votes = [{'pid': v.pid, 'tid': v.tid, 'vote': v.sign, 'created': v.t_ms} for v in ds.votes] return Conversation('vw').update_votes({'votes': votes}) - def test_vw_base_and_group_identical_across_modes(self, monkeypatch): - imp = self._vw_conv(monkeypatch, 'improved') - leg = self._vw_conv(monkeypatch, 'clojure-legacy') - assert _partition(imp.base_clusters) == _partition(leg.base_clusters) - assert _partition(imp.group_clusters) == _partition(leg.group_clusters) + def test_vw_base_and_group_deterministic(self, monkeypatch): + run1 = self._vw_conv(monkeypatch) + run2 = self._vw_conv(monkeypatch) + assert _partition(run1.base_clusters) == _partition(run2.base_clusters) + assert _partition(run1.group_clusters) == _partition(run2.group_clusters) diff --git a/delphi/tests/test_group_k_smoother.py b/delphi/tests/test_group_k_smoother.py index de5681075..494d5527f 100644 --- a/delphi/tests/test_group_k_smoother.py +++ b/delphi/tests/test_group_k_smoother.py @@ -10,8 +10,8 @@ 1. Pure-function unit tests (buffer counting, reset-on-change, clamp, first-tick, higher-k tie-break) — fast, deterministic. 2. A chained-update_votes integration test proving the smoother is threaded - across ticks in 'clojure-legacy' mode (no flicker on brief alternation, - switch after 4 consecutive) and is inert in 'improved' mode. + across ticks (no flicker on brief alternation, switch after 4 + consecutive). """ import os @@ -195,31 +195,6 @@ def test_legacy_no_flicker_then_switch_after_four(self, monkeypatch): # group_clusters is picked from the smoothed k and is never None/empty. assert conv.group_clusters, "group_clusters must be populated" - def test_mode_switch_to_improved_clears_legacy_state(self, monkeypatch): - """After a clojure-legacy tick populated the warm-start state, a tick - under improved mode must CLEAR it (not silently retain stale memory).""" - prefs = [2, 2] - self._setup(monkeypatch, 'clojure-legacy', prefs) - conv = Conversation('smooth').update_votes(_many_ptpt_votes()) - assert conv.group_clusterings and conv.group_k_smoother - - monkeypatch.setenv(ENGINE_MODE_ENV_VAR, 'improved') - conv = conv.update_votes(_REPEAT_VOTE) - assert conv.group_k_smoother == {} - assert conv.group_clusterings == {} - assert conv.group_clusters, "group_clusters must be populated" - - def test_improved_mode_leaves_smoother_inert(self, monkeypatch): - prefs = [3, 3, 3, 3] - self._setup(monkeypatch, 'improved', prefs) - conv = Conversation('smooth').update_votes(_many_ptpt_votes()) - # Improved mode never touches the smoother/clusterings state. - assert conv.group_k_smoother == {} - assert conv.group_clusterings == {} - # But still produces group clusters via the untouched best_k path. - assert conv.group_clusters, "group_clusters must be populated" - - def _degenerate_votes(n_ptpts=20, n_cmts=8): """All participants vote identically -> a single base cluster (degenerate).""" return {'votes': [{'pid': f'p{i}', 'tid': f'c{t}', 'vote': 1.0} @@ -229,8 +204,8 @@ def _degenerate_votes(n_ptpts=20, n_cmts=8): class TestDegenerateTickSmoother: """P6a: on a <2-base-cluster degenerate tick with a NON-empty conv, Clojure's max-k-fn is still >= 2 (conversation.clj:273-279), so its graph feeds this_k=2 - to the group-k smoother and ADVANCES it. Legacy mode must mirror that instead - of freezing the smoother memory. Improved mode carries no smoother state.""" + to the group-k smoother and ADVANCES it. The engine must mirror that instead + of freezing the smoother memory.""" def _mode(self, monkeypatch, mode): monkeypatch.delenv(PCA_IMPL_ENV_VAR, raising=False) @@ -254,9 +229,3 @@ def test_legacy_degenerate_tick_accumulates_count_across_ticks(self, monkeypatch assert len(conv.base_clusters) < 2 assert conv.group_k_smoother.get('last_k') == 2 assert conv.group_k_smoother.get('last_k_count') == 2 - - def test_improved_degenerate_tick_leaves_smoother_inert(self, monkeypatch): - self._mode(monkeypatch, 'improved') - conv = Conversation('deg').update_votes(_degenerate_votes()) - assert len(conv.base_clusters) < 2 - assert conv.group_k_smoother == {} # improved carries no smoother state diff --git a/delphi/tests/test_pca_warm_start.py b/delphi/tests/test_pca_warm_start.py index ff3e7389a..281776167 100644 --- a/delphi/tests/test_pca_warm_start.py +++ b/delphi/tests/test_pca_warm_start.py @@ -11,10 +11,11 @@ 1. pca_project_dataframe threads start_vectors into powerit_pca, and refuses to run sklearn when warm-start vectors are required (sklearn cannot inject start vectors) — it warns and falls back to power iteration. - 2. In 'clojure-legacy' mode, a second recompute tick feeds tick-1's comps to - powerit_pca as start_vectors; in 'improved' mode it stays None (cold). + 2. A second recompute tick feeds tick-1's comps to powerit_pca as + start_vectors (the engine's only path since the mode collapse; the + former improved-mode cold recompute is parked: + POST_CUTOVER_IMPROVEMENTS.md item 8). 3. Warm-started tick-2 comps stay close in angle to tick-1 (reduced jitter). - 4. The cold FIRST tick is identical across the two modes (no prev state). """ import os @@ -187,12 +188,6 @@ def test_legacy_tick2_receives_tick1_comps(self, monkeypatch): np.testing.assert_allclose(np.asarray(recorded[1]), np.asarray(conv1.pca['comps'])) - def test_improved_tick2_receives_none(self, monkeypatch): - conv1, conv2, recorded = self._run_two_ticks(monkeypatch, 'improved') - assert len(recorded) == 2 - assert recorded[0] is None - assert recorded[1] is None # cold recompute every tick - def test_legacy_warm_comps_close_in_angle(self, monkeypatch): conv1, conv2, _ = self._run_two_ticks(monkeypatch, 'clojure-legacy') # Same column set across ticks, so comps are directly comparable. @@ -219,9 +214,3 @@ def test_legacy_prev_pca_without_comps_falls_back_to_cold(self, monkeypatch): assert recorded[-1] is None, ( f"prev_pca={degenerate!r} must cold-start, not seed powerit" ) - - def test_cold_first_tick_identical_across_modes(self, monkeypatch): - conv1_imp, _, _ = self._run_two_ticks(monkeypatch, 'improved') - conv1_leg, _, _ = self._run_two_ticks(monkeypatch, 'clojure-legacy') - np.testing.assert_array_equal(conv1_imp.pca['comps'], conv1_leg.pca['comps']) - np.testing.assert_array_equal(conv1_imp.pca['center'], conv1_leg.pca['center'])