|
| 1 | +;; 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 <http://www.gnu.org/licenses/>. |
| 2 | + |
| 3 | +(ns conv-edge-cases-test |
| 4 | + "Tests for edge cases in the conversation update pipeline that can cause |
| 5 | + crashes on large or evolving conversations." |
| 6 | + (:require [clojure.test :refer [deftest testing is]] |
| 7 | + [polismath.math.repness :as repness] |
| 8 | + [polismath.math.named-matrix :as nm] |
| 9 | + [polismath.math.conversation :as conversation])) |
| 10 | + |
| 11 | + |
| 12 | +;; ============================================================================ |
| 13 | +;; Bug 1: conv-repness crashes with empty group-clusters |
| 14 | +;; |
| 15 | +;; When group-clusters is nil or empty, (apply map f []) returns a transducer |
| 16 | +;; (function object) instead of a sequence. Downstream code that tries to |
| 17 | +;; iterate over :stats gets: |
| 18 | +;; IllegalArgumentException: Don't know how to create ISeq from: clojure.core$map$fn__XXXX |
| 19 | +;; |
| 20 | +;; This happens in production when the k-smoother holds a stale smoothed-k |
| 21 | +;; that no longer exists in group-clusterings (see Bug 2 below). |
| 22 | +;; ============================================================================ |
| 23 | + |
| 24 | +(def test-rating-mat |
| 25 | + (nm/named-matrix |
| 26 | + [:p1 :p2 :p3 :p4] |
| 27 | + [:c1 :c2 :c3] |
| 28 | + [[-1 1 0] |
| 29 | + [ 1 -1 1] |
| 30 | + [-1 -1 0] |
| 31 | + [ 1 1 -1]])) |
| 32 | + |
| 33 | +(def test-base-clusters |
| 34 | + [{:id :b1 :members [:p1 :p2]} |
| 35 | + {:id :b2 :members [:p3 :p4]}]) |
| 36 | + |
| 37 | + |
| 38 | +(deftest conv-repness-with-empty-group-clusters |
| 39 | + (testing "conv-repness throws an informative error when group-clusters is empty" |
| 40 | + ;; Without the guard, (apply map f []) silently returns a transducer that |
| 41 | + ;; crashes downstream with a cryptic "Don't know how to create ISeq" error. |
| 42 | + ;; The guard throws immediately with a message pointing to the root cause. |
| 43 | + (is (thrown-with-msg? IllegalArgumentException |
| 44 | + #"group-clusters is nil or empty" |
| 45 | + (repness/conv-repness test-rating-mat [] test-base-clusters)))) |
| 46 | + |
| 47 | + (testing "conv-repness throws an informative error when group-clusters is nil" |
| 48 | + (is (thrown-with-msg? IllegalArgumentException |
| 49 | + #"group-clusters is nil or empty" |
| 50 | + (repness/conv-repness test-rating-mat nil test-base-clusters))))) |
| 51 | + |
| 52 | + |
| 53 | +;; ============================================================================ |
| 54 | +;; Bug 2: stale smoothed-k in group-k-smoother |
| 55 | +;; |
| 56 | +;; The k-smoother preserves the old smoothed-k for :group-k-buffer iterations |
| 57 | +;; to dampen oscillation. But it doesn't check whether the old k still exists |
| 58 | +;; in the current group-clusterings. When the number of non-empty base-clusters |
| 59 | +;; drops (e.g. due to PCA rotation after a batch of new votes), the k-range |
| 60 | +;; shrinks and (get group-clusterings old-smoothed-k) returns nil. |
| 61 | +;; |
| 62 | +;; We test this by feeding the smoother a conv with a previous smoothed-k=5 |
| 63 | +;; and group-clusterings that only contain k=2,3. |
| 64 | +;; ============================================================================ |
| 65 | + |
| 66 | +(deftest stale-smoothed-k-is-clamped-to-available-group-clusters |
| 67 | + (testing "smoothed-k is clamped to an available range so group-clusters stays non-nil" |
| 68 | + ;; Simulate: previous iteration had smoothed-k=5, but current base-cluster |
| 69 | + ;; count only supports k=2,3 |
| 70 | + (let [;; Minimal group clusterings for k=2 and k=3 |
| 71 | + dummy-clustering-k2 [{:id 0 :members [:b1]} {:id 1 :members [:b2]}] |
| 72 | + dummy-clustering-k3 [{:id 0 :members [:b1]} {:id 1 :members [:b2]} {:id 2 :members []}] |
| 73 | + group-clusterings {2 dummy-clustering-k2 |
| 74 | + 3 dummy-clustering-k3} |
| 75 | + ;; Old smoother state with smoothed-k=5 (stale!) |
| 76 | + old-smoother {:last-k 5 :last-k-count 1 :smoothed-k 5} |
| 77 | + ;; Current smoother picks the best available k, but preserves old smoothed-k |
| 78 | + ;; because buffer hasn't been exceeded |
| 79 | + group-clusterings-silhouettes {2 0.6, 3 0.8} |
| 80 | + smoother-fnk (:group-k-smoother conversation/small-conv-update-graph) |
| 81 | + new-smoother (smoother-fnk |
| 82 | + {:conv {:group-k-smoother old-smoother} |
| 83 | + :group-clusterings group-clusterings |
| 84 | + :group-clusterings-silhouettes group-clusterings-silhouettes |
| 85 | + :opts' {:group-k-buffer 4}}) |
| 86 | + ;; Now look up group-clusters using the smoother's smoothed-k |
| 87 | + group-clusters (get group-clusterings (:smoothed-k new-smoother))] |
| 88 | + |
| 89 | + ;; With the current (unfixed) code, smoothed-k stays at 5 and the lookup returns nil. |
| 90 | + ;; After the fix, smoothed-k should be clamped to an available k. |
| 91 | + (testing "smoothed-k should be a key that exists in group-clusterings" |
| 92 | + (is (contains? group-clusterings (:smoothed-k new-smoother)) |
| 93 | + (str "smoothed-k=" (:smoothed-k new-smoother) |
| 94 | + " not in " (keys group-clusterings)))) |
| 95 | + |
| 96 | + (testing "group-clusters should not be nil" |
| 97 | + (is (some? group-clusters) |
| 98 | + "group-clusters lookup must not return nil"))))) |
| 99 | + |
| 100 | + |
| 101 | +;; ============================================================================ |
| 102 | +;; Bug 3 (colleague's fix): agg-bucket-votes-for-tid with unknown pids |
| 103 | +;; |
| 104 | +;; When base-cluster members include pids not present in the rating matrix |
| 105 | +;; (e.g. after incremental updates where clusters lag behind the matrix), |
| 106 | +;; the pid-to-row lookup returns nil, and (get person-rows nil) can fail |
| 107 | +;; depending on the matrix implementation. |
| 108 | +;; ============================================================================ |
| 109 | + |
| 110 | +(deftest agg-bucket-votes-unknown-pid |
| 111 | + (testing "agg-bucket-votes-for-tid handles pids not in the rating matrix" |
| 112 | + (let [;; rating-mat only has :p1 and :p2 |
| 113 | + rating-mat (nm/named-matrix |
| 114 | + [:p1 :p2] |
| 115 | + [:c1 :c2] |
| 116 | + [[-1 1] |
| 117 | + [ 1 0]]) |
| 118 | + ;; but bid-to-pid references :p3 which doesn't exist in rating-mat |
| 119 | + bid-to-pid [[:p1 :p3] [:p2]] |
| 120 | + result (conversation/agg-bucket-votes-for-tid |
| 121 | + bid-to-pid rating-mat number? :c1)] |
| 122 | + ;; Should not throw; :p3's vote is nil, filtered out by number? |
| 123 | + (is (vector? result)) |
| 124 | + ;; bucket 0 has :p1 (voted) and :p3 (unknown → nil, filtered out) → count 1 |
| 125 | + (is (= 1 (first result))) |
| 126 | + ;; bucket 1 has :p2 (voted) → count 1 |
| 127 | + (is (= 1 (second result)))))) |
0 commit comments