Skip to content

Commit d8d3f24

Browse files
Julien Musicclaude
authored andcommitted
Fix D15: match Clojure moderation handling (zero out columns, don't remove)
Python's _apply_moderation() removed moderated-out comment columns entirely from rating_mat. Clojure's zero-out-columns (named_matrix.clj:214-230) sets all values in moderated columns to 0, preserving matrix structure. Change _apply_moderation() to zero out moderated columns instead of removing them, so that: - rating_mat retains the same column count as raw_rating_mat - tids output includes moderated-out tids (matching Clojure) - Matrix dimensions are preserved through the pipeline Moderated-out participants (rows) are still removed — unchanged. Zeroed columns have no signal (na=0, nd=0), so they fail all significance tests and are effectively excluded from repness/consensus/PCA, but their presence preserves index stability for downstream consumers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent baceacd commit d8d3f24

7 files changed

Lines changed: 327 additions & 101 deletions

File tree

‎delphi/docs/CLJ-PARITY-FIXES-JOURNAL.md‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,60 @@ to create a new worktree. If yes, provide a prompt they can use to start that se
711711
712712
---
713713

714+
## Session: Fix D15 — Moderation Handling (2026-03-16)
715+
716+
### Branch: `jc/clj-parity-d15-moderation-handling-zeros-vs-removes`
717+
718+
### What was done
719+
720+
Fixed D15: Python now zeros out moderated-out comment columns instead of removing them,
721+
matching Clojure's `zero-out-columns` behavior (named_matrix.clj:214-230).
722+
723+
**The discrepancy**: Python's `_apply_moderation()` removed moderated-out columns from
724+
`rating_mat` entirely (`raw_rating_mat.loc[keep_ptpts, keep_comments]`). Clojure zeros
725+
them out (`matrix/set-column m' i 0`), preserving matrix structure.
726+
727+
**The fix**: Changed `_apply_moderation()` to:
728+
1. Still remove moderated-out participants (rows) — unchanged
729+
2. Zero out moderated-out comment columns instead of removing them
730+
3. `rating_mat` now has the same column count as `raw_rating_mat`
731+
732+
**Impact on downstream**:
733+
- `tids` output now includes moderated-out tids (matching Clojure)
734+
- PCA: zeroed columns contribute nothing to variance, so PCA results are effectively identical
735+
- Repness: zeroed columns get na=0, nd=0, failing significance — effectively excluded
736+
- Vote counting: `user-vote-counts` in `to_math_blob()` uses `rating_mat`, so moderated
737+
columns now count as "pass" votes (matching Clojure's behavior with zeroed columns)
738+
739+
### Tests
740+
741+
**New synthetic tests** (`TestD15SyntheticModeration`, 5 tests):
742+
- `test_zeroing_preserves_columns` — moderated columns still present
743+
- `test_zeroed_columns_are_all_zero` — moderated column values are 0.0
744+
- `test_non_moderated_columns_unchanged` — other columns retain original values
745+
- `test_empty_moderation_no_change` — no-op when no moderation
746+
- `test_moderate_nonexistent_tid` — graceful handling of unknown tids
747+
748+
**Enhanced real-data tests** (`TestD15ModerationHandling`, 2 tests):
749+
- `test_moderated_comments_zeroed_not_removed` — applies mod-out from Clojure blob, checks column count and zeroed values
750+
- `test_tids_include_moderated` — verifies moderated tids remain in rating_mat columns
751+
752+
**Updated existing tests**:
753+
- `test_conversation.py::test_moderation` — updated to expect zeroed columns
754+
- `test_conversation.py::test_update_moderation` — same
755+
- `test_discrepancy_fixes.py::TestD2cVoteCountSource::test_n_cmts_includes_moderated_out_comments` — updated comment count assertion
756+
757+
### Test results
758+
759+
- Public datasets: **328 passed, 0 failed, 6 skipped, 56 xfailed**
760+
- Private datasets: 13 failures — all **pre-existing** (golden snapshot staleness from earlier fixes, not D15-related). Verified by running parent branch.
761+
762+
### What's next
763+
764+
- D12 (comment priorities) or D1/D1b (PCA sign flips) — per plan ordering
765+
766+
---
767+
714768
## Notes for Future Sessions
715769

716770
- Private datasets are in `delphi/real_data/.local/` (separate git repo, linked via `link-to-polis-worktree.sh`)

‎delphi/docs/PLAN_DISCREPANCY_FIXES.md‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ Because this work will span multiple Claude Code sessions, we maintain:
4747
### Testing Principles
4848

4949
- **Granular tests per discrepancy**: Not just overall regression — each fix gets its own targeted test checking the specific aspect it addresses. Multiple discrepancies may affect `test_basic_outputs`; we need to see incremental improvement per fix.
50+
- **Clojure blob comparison is MANDATORY**: Every fix PR must include tests that compare Python output to actual Clojure math blob values — not just formula re-implementation tests (which are tautological: they only verify our code matches our reading of the Clojure, not that it matches Clojure's actual output). The Clojure blob is the ground truth oracle.
51+
- **Stage isolation via blob injection**: Since upstream stages (PCA, clustering) may not match between Python and Clojure, tests must inject Clojure blob values as inputs to the stage being tested, then compare outputs. For example: to test `prop_test` (D5), extract `n-success` and `n-trials` from the Clojure blob's `repness` entries, feed them to Python's `prop_test()`, and compare the result to the blob's `p-test`. This isolates each stage from upstream divergence.
52+
- **Blob fields available for injection/comparison**: The Clojure cold-start blob provides per-group repness entries with: `n-success` (=na), `n-trials` (=ns), `p-success` (=pa), `p-test` (=pat), `repness` (=ra), `repness-test` (=rat), `repful-for`, `best-agree`, `tid`. Also: `group-clusters` (memberships), `group-votes` (per-group vote counts), `consensus` (selected consensus comments), `comment-priorities` (per-tid priority values), `in-conv` (participant list).
5053
- **Targeted pipeline-stage tests**: For D2/D3 (participant filtering, clustering), check in-conv count, cluster count, and cluster memberships against Clojure blob. For D12, check comment-priorities against Clojure blob.
5154
- **All datasets, not just biodiversity**: Every fix must pass on ALL datasets. biodiversity is just one reference among many.
5255
- **Synthetic edge-case tests**: Every time we discover an edge case specific to one conversation, extract it into a synthetic unit test with made-up data (never real data from private datasets). These run fast and document the intent clearly.
@@ -441,6 +444,38 @@ By this point, we should have good test coverage from all the per-discrepancy te
441444

442445
---
443446

447+
### Investigation: Cold-Start K Divergence (after D15, before D12)
448+
449+
**Prerequisite**: All cold-start-relevant upstream fixes complete: D2/D2c/D2b (in-conv,
450+
vote counts, sort order), D15 (moderation handling). Note: D1 (PCA sign flips) only
451+
affects incremental updates — on cold start there are no previous components to align to.
452+
453+
After D15, the rating matrix construction, in-conv filtering, and PCA inputs should all
454+
match Clojure. Both implementations use silhouette for k-selection. Yet on vw, Python
455+
selects k=4 while Clojure selects k=2.
456+
457+
**Investigation steps**:
458+
459+
1. **PCA component comparison**: Feed the same rating matrix to both sklearn TruncatedSVD
460+
and a Python reimplementation of Clojure's power iteration. Quantify divergence
461+
(cosine similarity per component, Frobenius norm).
462+
2. **Projection comparison**: Inject Clojure blob's PCA components into Python's
463+
clustering path. Does k now match?
464+
3. **Base-cluster comparison**: Given the same projections, compare k-means centroids
465+
and member assignments. Check initialization (Clojure uses first-k-distinct centers
466+
from base clusters — does Python match?).
467+
4. **Silhouette score comparison**: Given the same base clusters, compare per-k
468+
silhouette scores. Are the scores close but the winner differs?
469+
5. **All datasets**: Run on all datasets with cold-start blobs, not just vw.
470+
471+
**Outcome**: Either (a) identify a fixable discrepancy that makes k match, or
472+
(b) document the inherent numerical divergence between sklearn SVD and Clojure
473+
power iteration, and establish tolerance bounds for k agreement in tests.
474+
475+
See `delphi/docs/HANDOFF_K_DIVERGENCE_INVESTIGATION.md` for detailed context.
476+
477+
---
478+
444479
### Explicitly Deferred
445480

446481
- **D13 — Subgroup Clustering**: Not implemented in Python, never used by TypeScript consumers. No fix needed.
@@ -470,14 +505,15 @@ By this point, we should have good test coverage from all the per-discrepancy te
470505
| D5 | Proportion test | **PR 4** | — | **DONE** ✓ |
471506
| D6 | Two-proportion test | **PR 5** | — | **DONE** ✓ |
472507
| D7 | Repness metric | PR 6 | — | **DONE** ✓ |
473-
| D8 | Finalize cmt stats | PR 7 | — | Fix |
508+
| D8 | Finalize cmt stats | PR 7 | — | **DONE** ✓ |
474509
| D9 | Z-score thresholds | **PR 3** | **#2446** | **DONE** ✓ |
475510
| D10 | Rep comment selection | PR 8 | — | Fix (with legacy env var) |
476511
| D11 | Consensus selection | PR 9 | — | Fix (with legacy env var) |
477512
| D12 | Comment priorities | PR 11 | — | Fix (implement from scratch) |
478513
| D13 | Subgroup clustering | — | — | **Deferred** (unused) |
479514
| D14 | Large conv optimization | — | — | **Deferred** (Python fast enough) |
480-
| D15 | Moderation handling | PR 12 | — | Fix |
515+
| D15 | Moderation handling | PR 12 | — | **DONE** ✓ |
516+
| K-inv | Cold-start k divergence | (investigation) | — | Branch off D15 (D2+D15 done, clustering independent of repness) |
481517
| Replay | Replay infrastructure (A/B/C) | — | — | NOT BUILT — D3/D1 used synthetic tests only. Needed for incremental blob comparison. |
482518

483519
### Non-discrepancy PRs in the stack

‎delphi/polismath/conversation/conversation.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -297,15 +297,23 @@ def update_votes(self,
297297
def _apply_moderation(self) -> None:
298298
"""
299299
Apply moderation settings to create filtered rating matrix.
300+
301+
Matches Clojure behavior (named_matrix.clj:214-230):
302+
- Moderated-out participants are removed (rows dropped)
303+
- Moderated-out comments are ZEROED OUT, not removed — the column
304+
stays in the matrix with all values set to 0. This preserves
305+
matrix structure so that tids, column indices, and dimensions
306+
match between Python and Clojure.
300307
"""
301-
# Filter out moderated participants and comments, and keep them sorted!
302-
# Note: set operations are unordered, hence the extra sort.
303-
# Natural sort: preserves types and sorts numerically when possible
308+
# Filter out moderated participants (remove rows)
304309
keep_ptpts = natsorted(list(set(self.raw_rating_mat.index) - set(self.mod_out_ptpts)))
305-
keep_comments = natsorted(list(set(self.raw_rating_mat.columns) - set(self.mod_out_tids)))
306-
307-
# Create filtered matrix
308-
self.rating_mat = self.raw_rating_mat.loc[keep_ptpts, keep_comments]
310+
self.rating_mat = self.raw_rating_mat.loc[keep_ptpts].copy()
311+
312+
# Zero out moderated-out comments (keep columns, set values to 0)
313+
# Clojure: (matrix/set-column m' i 0) — zeroes the column
314+
mod_cols = [c for c in self.mod_out_tids if c in self.rating_mat.columns]
315+
if mod_cols:
316+
self.rating_mat[mod_cols] = 0.0
309317

310318
def _compute_vote_stats(self) -> None:
311319
"""

‎delphi/real_data/r4tykwac8thvzv35jrn53-biodiversity/golden_snapshot.json‎

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"n_comments_in_csv": 316,
99
"n_participants_in_csv": 536,
1010
"fixed_timestamp": 1700000000000,
11-
"recorded_at": "2026-03-24T11:45:32.247863"
11+
"recorded_at": "2026-03-24T11:46:25.829282"
1212
},
1313
"stages": {
1414
"empty": {
@@ -46,7 +46,7 @@
4646
"disagree": [],
4747
"comment-stats": {}
4848
},
49-
"math_tick": 27733
49+
"math_tick": 27787
5050
},
5151
"after_load_no_compute": {
5252
"last_updated": 1700000000000,
@@ -8057,7 +8057,7 @@
80578057
"disagree": [],
80588058
"comment-stats": {}
80598059
},
8060-
"math_tick": 27733
8060+
"math_tick": 27787
80618061
},
80628062
"after_pca": {
80638063
"last_updated": 1700000000000,
@@ -19166,7 +19166,7 @@
1916619166
"disagree": [],
1916719167
"comment-stats": {}
1916819168
},
19169-
"math_tick": 27733
19169+
"math_tick": 27788
1917019170
},
1917119171
"after_clustering": {
1917219172
"last_updated": 1700000000000,
@@ -35107,7 +35107,7 @@
3510735107
"disagree": [],
3510835108
"comment-stats": {}
3510935109
},
35110-
"math_tick": 27733
35110+
"math_tick": 27788
3511135111
},
3511235112
"after_full_recompute": {
3511335113
"last_updated": 1700000000000,
@@ -61837,7 +61837,7 @@
6183761837
"disagree": [],
6183861838
"comment-stats": {}
6183961839
},
61840-
"math_tick": 27733
61840+
"math_tick": 27788
6184161841
},
6184261842
"full_data_export": {
6184361843
"conversation_id": "biodiversity",
@@ -81312,57 +81312,57 @@
8131281312
},
8131381313
"timing_stats": {
8131481314
"empty": {
81315-
"mean": 0.0002450136623034875,
81316-
"std": 4.3639502782565e-06,
81315+
"mean": 0.02019661133332799,
81316+
"std": 0.0345314761572571,
8131781317
"raw": [
81318-
0.0002470829931553453,
81319-
0.00023999999393709004,
81320-
0.000247957999818027
81318+
0.00025466700026299804,
81319+
0.0002650420065037906,
81320+
0.06007012499321718
8132181321
]
8132281322
},
8132381323
"after_load_no_compute": {
81324-
"mean": 0.04123843066918198,
81325-
"std": 0.014000114032996457,
81324+
"mean": 0.048245472333898455,
81325+
"std": 0.01785033734259351,
8132681326
"raw": [
81327-
0.029898249995312653,
81328-
0.036930875008692965,
81329-
0.05688616700354032
81327+
0.030253250006353483,
81328+
0.04853270899911877,
81329+
0.06595045799622312
8133081330
]
8133181331
},
8133281332
"after_pca": {
81333-
"mean": 0.02450374999898486,
81334-
"std": 0.01127007663956127,
81333+
"mean": 0.06067052799820279,
81334+
"std": 0.01914579351316182,
8133581335
"raw": [
81336-
0.013971791995572858,
81337-
0.03638962499098852,
81338-
0.023149833010393195
81336+
0.06774112499260809,
81337+
0.0752754170098342,
81338+
0.03899504199216608
8133981339
]
8134081340
},
8134181341
"after_clustering": {
81342-
"mean": 0.17774104166892357,
81343-
"std": 0.11087459232976675,
81342+
"mean": 0.2687555973340447,
81343+
"std": 0.21711782082707126,
8134481344
"raw": [
81345-
0.07256712499656714,
81346-
0.16710608400171623,
81347-
0.29354991600848734
81345+
0.07044791700900532,
81346+
0.5007465409871656,
81347+
0.23507233400596306
8134881348
]
8134981349
},
8135081350
"after_full_recompute": {
81351-
"mean": 0.27918515300067764,
81352-
"std": 0.10193353078517546,
81351+
"mean": 0.561707333336623,
81352+
"std": 0.15916801118706728,
8135381353
"raw": [
81354-
0.3940678340004524,
81355-
0.19956449999881443,
81356-
0.24392312500276603
81354+
0.39767841700813733,
81355+
0.7155225000024075,
81356+
0.5719210829993244
8135781357
]
8135881358
},
8135981359
"full_data_export": {
81360-
"mean": 0.00023036100416599462,
81361-
"std": 4.601970059567263e-05,
81360+
"mean": 0.0025394716697822637,
81361+
"std": 0.0039485346647779385,
8136281362
"raw": [
81363-
0.0001994580088648945,
81364-
0.00028325000312179327,
81365-
0.0002083750005112961
81363+
0.0070985409984132275,
81364+
0.0002142080047633499,
81365+
0.0003056660061702132
8136681366
]
8136781367
}
8136881368
}

0 commit comments

Comments
 (0)