vimalk78 commited on
Commit
681be4a
·
1 Parent(s): 3b88621

feat: Add percentile-sorted probability visualization to debug tab

Browse files

- Implement Chart.js probability distribution visualization
- Sort chart by frequency percentile (100% → 0%) to reveal Gaussian targeting
- Add comprehensive probability distribution analysis documentation
- Enable statistical markers (μ, σ) with proper sampling zone visualization

Fixes visualization issue where probability-sorted charts couldn't show
difficulty-based frequency targeting effectiveness.

Signed-off-by: Vimal Kumar <[email protected]>

crossword-app/backend-py/docs/probability_distribution_analysis.md ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Probability Distribution Analysis: Theory vs. Practice
2
+
3
+ ## Executive Summary
4
+
5
+ This document analyzes the **actual behavior** of the crossword word selection system, complementing the theoretical framework described in [`composite_scoring_algorithm.md`](composite_scoring_algorithm.md). While the composite scoring theory is sound, empirical analysis reveals significant discrepancies between intended and actual behavior.
6
+
7
+ ### Key Findings
8
+ - **Similarity dominates**: Difficulty-based frequency preferences are too weak to create distinct selection patterns
9
+ - **Exponential distributions**: Actual probability distributions follow exponential decay, not normal distributions
10
+ - **Statistical misconceptions**: Using normal distribution concepts (μ ± σ) on exponentially decaying data is misleading
11
+ - **Mode-mean divergence**: Statistical measures don't represent where selections actually occur
12
+
13
+ ## Observed Probability Distributions
14
+
15
+ ### Data Source: Technology Topic Analysis
16
+ Using the debug visualization with `ENABLE_DEBUG_TAB=true`, we analyzed the actual probability distributions for different difficulties:
17
+
18
+ ```
19
+ Topic: Technology
20
+ Candidates: 150 words
21
+ Temperature: 0.2
22
+ Selection method: Softmax with composite scoring
23
+ ```
24
+
25
+ ### Empirical Results
26
+
27
+ #### Easy Difficulty
28
+ ```
29
+ Mean Position: Word #42 (IMPLEMENT)
30
+ Distribution Width (σ): 33.4 words
31
+ σ Sampling Zone: 70.5% of probability mass
32
+ σ Range: Words #9-#76
33
+ Top Probability: 2.3%
34
+ ```
35
+
36
+ #### Medium Difficulty
37
+ ```
38
+ Mean Position: Word #60 (COMPUTERIZED)
39
+ Distribution Width (σ): 42.9 words
40
+ σ Sampling Zone: 61.0% of probability mass
41
+ σ Range: Words #17-#103
42
+ Top Probability: 1.5%
43
+ ```
44
+
45
+ #### Hard Difficulty
46
+ ```
47
+ Mean Position: Word #37 (DIGITISATION)
48
+ Distribution Width (σ): 40.2 words
49
+ σ Sampling Zone: 82.1% of probability mass
50
+ σ Range: Words #1-#77
51
+ Top Probability: 4.1%
52
+ ```
53
+
54
+ ### Critical Observation
55
+ **All three difficulty levels show similar exponential decay patterns**, with only minor variations in peak height and mean position. This indicates the frequency-based difficulty targeting is not working as intended.
56
+
57
+ ## Statistical Misconceptions in Current Approach
58
+
59
+ ### The Mode-Mean Divergence Problem
60
+
61
+ The visualization shows a red line (μ) at positions 37-60, but the highest probability bars are at positions 0-5. This reveals a fundamental statistical concept:
62
+
63
+ ```
64
+ Distribution Type: Exponentially Decaying (Highly Skewed)
65
+
66
+ Mode (Peak): Position 0-3 (2-4% probability)
67
+ Median: Position ~15 (Where 50% of probability mass is reached)
68
+ Mean (μ): Position 37-60 (Weighted average position)
69
+ ```
70
+
71
+ ### Why μ is "Wrong" for Understanding Selection
72
+
73
+ In an exponential distribution with long tail:
74
+
75
+ 1. **Mode (0-3)**: Where individual words have highest probability
76
+ 2. **Practical sampling zone**: First 10-20 words contain ~60-80% of probability mass
77
+ 3. **Mean (37-60)**: Pulled far right by 100+ words with tiny probabilities
78
+
79
+ The mean doesn't represent where sampling actually occurs—it's mathematically correct but practically misleading.
80
+
81
+ ### Standard Deviation Misapplication
82
+
83
+ The σ visualization assumes a normal distribution where:
84
+ - **Normal assumption**: μ ± σ contains ~68% of probability mass
85
+ - **Our reality**: Exponential distribution with μ ± σ often missing the high-probability words entirely
86
+
87
+ For exponential distributions, percentiles or cumulative probability are more meaningful than standard deviation.
88
+
89
+ ## Actual vs. Expected Behavior Analysis
90
+
91
+ ### What Should Happen (Theory)
92
+ According to the composite scoring algorithm:
93
+
94
+ - **Easy**: Gaussian peak at 90th percentile → common words dominate
95
+ - **Medium**: Gaussian peak at 50th percentile → balanced selection
96
+ - **Hard**: Gaussian peak at 20th percentile → rare words favored
97
+
98
+ ### What Actually Happens (Empirical)
99
+ ```
100
+ Easy: MULTIMEDIA, TECH, TECHNOLOGY, IMPLEMENTING... (similar to others)
101
+ Medium: TECH, TECHNOLOGY, COMPUTERIZED, TECHNOLOGICAL... (similar pattern)
102
+ Hard: TECH, TECHNOLOGY, DIGITISATION, TECHNICIAN... (still similar)
103
+ ```
104
+
105
+ **All difficulties select similar high-similarity technology words**, regardless of their frequency percentiles.
106
+
107
+ ### Root Cause Analysis
108
+
109
+ The problem isn't in the Gaussian curves—they work correctly. The issue is in the composite formula:
110
+
111
+ ```python
112
+ # Current approach
113
+ composite = 0.5 * similarity + 0.5 * frequency_score
114
+
115
+ # What happens with real data:
116
+ # High-similarity word: similarity=0.9, wrong_freq_score=0.1
117
+ # → composite = 0.5*0.9 + 0.5*0.1 = 0.50
118
+
119
+ # Medium-similarity word: similarity=0.7, perfect_freq_score=1.0
120
+ # → composite = 0.5*0.7 + 0.5*1.0 = 0.85
121
+ ```
122
+
123
+ Even with perfect frequency alignment, a word needs **very high similarity** to compete with high-similarity words that have wrong frequency profiles.
124
+
125
+ ## Sampling Mechanics Deep Dive
126
+
127
+ ### np.random.choice Behavior
128
+ The selection uses `np.random.choice` with:
129
+ - **Without replacement**: Each word can only be selected once
130
+ - **Probability weighting**: Based on computed probabilities
131
+ - **Sample size**: 10 words from 150 candidates
132
+
133
+ ### Where Selections Actually Occur
134
+ Despite μ being at position 37-60, most actual selections come from positions 0-30 because:
135
+
136
+ 1. **High probabilities concentrate early**: First 20 words often have 60%+ of total probability
137
+ 2. **Without replacement effect**: Once high-probability words are chosen, selection moves to next-highest
138
+ 3. **Exponential decay**: Probability drops rapidly, making later positions unlikely
139
+
140
+ This explains why the green bars (selected words) appear mostly in the left portion of all distributions, regardless of where μ is located.
141
+
142
+ ## Better Visualization Approaches
143
+
144
+ ### Current Problems
145
+ - **μ ± σ assumes normality**: Not applicable to exponential distributions
146
+ - **Mean position misleading**: Doesn't show where selection actually occurs
147
+ - **Standard deviation meaningless**: For highly skewed distributions
148
+
149
+ ### Recommended Alternatives
150
+
151
+ #### 1. Cumulative Probability Visualization
152
+ ```
153
+ First 10 words: 45% of total probability mass
154
+ First 20 words: 65% of total probability mass
155
+ First 30 words: 78% of total probability mass
156
+ First 50 words: 90% of total probability mass
157
+ ```
158
+
159
+ #### 2. Percentile Markers Instead of μ ± σ
160
+ ```
161
+ P50 (Median): Position where 50% of probability mass is reached
162
+ P75: Position where 75% of probability mass is reached
163
+ P90: Position where 90% of probability mass is reached
164
+ ```
165
+
166
+ #### 3. Mode Annotation
167
+ - Show the actual peak (mode) position
168
+ - Mark the top-5 highest probability words
169
+ - Distinguish between statistical mean and practical selection zone
170
+
171
+ #### 4. Selection Concentration Metric
172
+ ```
173
+ Effective Selection Range: Positions covering 80% of selection probability
174
+ Selection Concentration: Gini coefficient of probability distribution
175
+ ```
176
+
177
+ ## Difficulty Differentiation Failure
178
+
179
+ ### Expected Pattern
180
+ Different difficulty levels should show visually distinct probability distribution patterns:
181
+ - **Easy**: Steep peak at common words, rapid falloff
182
+ - **Medium**: Moderate peak, balanced distribution
183
+ - **Hard**: Peak shifted toward rare words
184
+
185
+ ### Observed Pattern
186
+ All difficulties show similar exponential decay curves with:
187
+ - Similar-shaped distributions
188
+ - Similar high-probability words (TECH, TECHNOLOGY, etc.)
189
+ - Only minor differences in peak height and position
190
+
191
+ ### Quantitative Evidence
192
+ ```
193
+ Similarity scores of top words (all difficulties):
194
+ TECHNOLOGY: 0.95+ similarity to "technology"
195
+ TECH: 0.90+ similarity to "technology"
196
+ MULTIMEDIA: 0.85+ similarity to "technology"
197
+
198
+ These high semantic matches dominate regardless of their frequency percentiles.
199
+ ```
200
+
201
+ ## Recommended Fixes
202
+
203
+ ### 1. Multiplicative Scoring (Immediate Fix)
204
+ Replace additive formula with multiplicative gates:
205
+
206
+ ```python
207
+ # Current (additive)
208
+ composite = 0.5 * similarity + 0.5 * frequency_score
209
+
210
+ # Proposed (multiplicative)
211
+ frequency_modifier = get_frequency_modifier(percentile, difficulty)
212
+ composite = similarity * frequency_modifier
213
+
214
+ # Where frequency_modifier ranges 0.1-1.2 instead of 0.0-1.0
215
+ ```
216
+
217
+ **Effect**: Frequency acts as a gate rather than just another score component.
218
+
219
+ ### 2. Two-Stage Filtering (Structural Fix)
220
+ ```python
221
+ # Stage 1: Filter by frequency percentile ranges
222
+ easy_candidates = [w for w in candidates if w.percentile > 0.7] # Common words
223
+ medium_candidates = [w for w in candidates if 0.3 < w.percentile < 0.7] # Medium words
224
+ hard_candidates = [w for w in candidates if w.percentile < 0.3] # Rare words
225
+
226
+ # Stage 2: Rank filtered candidates by similarity
227
+ selected = softmax_selection(filtered_candidates, similarity_only=True)
228
+ ```
229
+
230
+ **Effect**: Guarantees different frequency pools for each difficulty, then optimizes within each pool.
231
+
232
+ ### 3. Exponential Temperature Scaling (Parameter Fix)
233
+ Use different temperature values by difficulty to create more distinct distributions:
234
+
235
+ ```python
236
+ easy_temperature = 0.1 # Very deterministic (sharp peak)
237
+ medium_temperature = 0.3 # Moderate randomness
238
+ hard_temperature = 0.2 # Deterministic but different peak
239
+ ```
240
+
241
+ ### 4. Adaptive Frequency Weights (Dynamic Fix)
242
+ ```python
243
+ # Calculate frequency dominance needed to overcome similarity differences
244
+ max_similarity_diff = max_similarity - min_similarity # e.g., 0.95 - 0.6 = 0.35
245
+ required_freq_weight = max_similarity_diff / (1 - max_similarity_diff) # e.g., 0.35/0.65 ≈ 0.54
246
+
247
+ # Use higher frequency weight when similarity ranges are wide
248
+ adaptive_weight = min(0.8, required_freq_weight)
249
+ ```
250
+
251
+ ## Empirical Data Summary
252
+
253
+ ### Word Selection Patterns (Technology Topic)
254
+ ```
255
+ Easy Mode Top Selections:
256
+ - MULTIMEDIA (percentile: ?, similarity: high)
257
+ - IMPLEMENT (percentile: ?, similarity: high)
258
+ - TECHNOLOGICAL (percentile: ?, similarity: high)
259
+
260
+ Hard Mode Top Selections:
261
+ - TECH (percentile: ?, similarity: very high)
262
+ - DIGITISATION (percentile: likely low, similarity: high)
263
+ - TECHNICIAN (percentile: ?, similarity: high)
264
+ ```
265
+
266
+ ### Statistical Summary
267
+ - **σ Width Variation**: Easy (33.4) vs Medium (42.9) vs Hard (40.2) - only 28% difference
268
+ - **Peak Variation**: 1.5% to 4.1% - moderate difference
269
+ - **Mean Position Variation**: Position 37 to 60 - 62% range but all in middle zone
270
+ - **Selection Concentration**: Most selections from first 30 words in all difficulties
271
+
272
+ ## Conclusions
273
+
274
+ ### The Core Problem
275
+ The difficulty-aware word selection system is theoretically sound but practically ineffective because:
276
+
277
+ 1. **Semantic similarity signals are too strong** compared to frequency signals
278
+ 2. **Additive scoring allows high-similarity words to dominate** regardless of frequency appropriateness
279
+ 3. **Statistical visualization assumes normal distributions** but data is exponentially skewed
280
+
281
+ ### Success Metrics for Fixes
282
+ A working system should show:
283
+
284
+ 1. **Visually distinct probability distributions** for each difficulty
285
+ 2. **Different word frequency profiles** in actual selections
286
+ 3. **Mode and mean alignment** with intended difficulty targets
287
+ 4. **Meaningful σ ranges** that represent actual selection zones
288
+
289
+ ### Next Steps
290
+ 1. Implement multiplicative scoring or two-stage filtering
291
+ 2. Update visualization to use percentiles instead of μ ± σ
292
+ 3. Collect empirical data on word frequency percentiles in actual selections
293
+ 4. Validate fixes show distinct patterns across difficulties
294
+
295
+ ---
296
+
297
+ *This analysis represents empirical findings from the debug visualization system, revealing gaps between the theoretical composite scoring model and its practical implementation.*
crossword-app/backend-py/src/services/thematic_word_service.py CHANGED
@@ -744,7 +744,7 @@ class ThematicWordService:
744
  return probabilities
745
 
746
  def _softmax_weighted_selection(self, candidates: List[Dict[str, Any]],
747
- num_words: int, temperature: float = None, difficulty: str = "medium") -> List[Dict[str, Any]]:
748
  """
749
  Select words using softmax-based probabilistic sampling weighted by composite scores.
750
 
@@ -784,10 +784,17 @@ class ThematicWordService:
784
  difficulty: Difficulty level ("easy", "medium", "hard") for frequency weighting
785
 
786
  Returns:
787
- Selected word dictionaries, sampled without replacement according to composite probabilities
 
 
788
  """
789
  if len(candidates) <= num_words:
790
- return candidates
 
 
 
 
 
791
 
792
  if temperature is None:
793
  temperature = self.similarity_temperature
@@ -832,6 +839,7 @@ class ThematicWordService:
832
 
833
  # Return selected candidates
834
  selected_candidates = [candidates[i] for i in selected_indices]
 
835
 
836
  logger.info(f"🎲 Composite softmax selection (T={temperature:.2f}, difficulty={difficulty}): {len(selected_candidates)} from {len(candidates)} candidates")
837
 
@@ -845,7 +853,36 @@ class ThematicWordService:
845
  tier = word_data.get('tier', 'unknown')
846
  logger.info(f" {word:<15} sim:{similarity:.3f} perc:{percentile:.3f} comp:{composite:.3f} ({tier})")
847
 
848
- return selected_candidates
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
849
 
850
  def _detect_multiple_themes(self, inputs: List[str], max_themes: int = 3) -> List[np.ndarray]:
851
  """Detect multiple themes using clustering."""
@@ -1167,12 +1204,13 @@ class ThematicWordService:
1167
  final_words = []
1168
 
1169
  # Select words using either softmax weighted selection or traditional random selection
 
1170
  if self.use_softmax_selection:
1171
  logger.info(f"🎲 Using softmax weighted selection on all {len(candidate_words)} candidates (temperature: {self.similarity_temperature})")
1172
 
1173
  # Apply softmax selection to ALL candidate words regardless of clue quality
1174
  if len(candidate_words) > requested_words:
1175
- selected_words = self._softmax_weighted_selection(candidate_words, requested_words, difficulty=difficulty)
1176
  final_words.extend(selected_words)
1177
  else:
1178
  final_words.extend(candidate_words) # Take all words if not enough
@@ -1243,6 +1281,11 @@ class ThematicWordService:
1243
  for word_data in final_words
1244
  ]
1245
  }
 
 
 
 
 
1246
  result["debug"] = debug_data
1247
  logger.info(f"🐛 Debug data collected: {len(debug_data['thematic_pool'])} thematic words, {len(debug_data['candidate_words'])} candidates, {len(debug_data['selected_words'])} selected")
1248
 
 
744
  return probabilities
745
 
746
  def _softmax_weighted_selection(self, candidates: List[Dict[str, Any]],
747
+ num_words: int, temperature: float = None, difficulty: str = "medium") -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
748
  """
749
  Select words using softmax-based probabilistic sampling weighted by composite scores.
750
 
 
784
  difficulty: Difficulty level ("easy", "medium", "hard") for frequency weighting
785
 
786
  Returns:
787
+ Tuple of (selected_word_dictionaries, probability_distribution_data)
788
+ - selected_word_dictionaries: Words chosen for crossword
789
+ - probability_distribution_data: Dict with candidate probabilities for debug visualization
790
  """
791
  if len(candidates) <= num_words:
792
+ # Return all candidates with trivial probability distribution
793
+ prob_data = {
794
+ "probabilities": [{"word": c["word"], "probability": 1.0/len(candidates), "composite_score": 0.0, "selected": True, "rank": i+1}
795
+ for i, c in enumerate(candidates)]
796
+ }
797
+ return candidates, prob_data
798
 
799
  if temperature is None:
800
  temperature = self.similarity_temperature
 
839
 
840
  # Return selected candidates
841
  selected_candidates = [candidates[i] for i in selected_indices]
842
+ selected_word_set = {candidates[i]["word"] for i in selected_indices}
843
 
844
  logger.info(f"🎲 Composite softmax selection (T={temperature:.2f}, difficulty={difficulty}): {len(selected_candidates)} from {len(candidates)} candidates")
845
 
 
853
  tier = word_data.get('tier', 'unknown')
854
  logger.info(f" {word:<15} sim:{similarity:.3f} perc:{percentile:.3f} comp:{composite:.3f} ({tier})")
855
 
856
+ # Create probability distribution data for debug visualization
857
+ prob_distribution = []
858
+ for i, candidate in enumerate(candidates):
859
+ prob_distribution.append({
860
+ "word": candidate["word"],
861
+ "probability": float(probabilities[i]),
862
+ "composite_score": float(composite_scores[i]),
863
+ "selected": candidate["word"] in selected_word_set,
864
+ "rank": i + 1,
865
+ "similarity": candidate["similarity"],
866
+ "tier": candidate.get("tier", "unknown"),
867
+ "percentile": self.word_percentiles.get(candidate["word"].lower(), 0.0)
868
+ })
869
+
870
+ # Sort by probability descending for display
871
+ prob_distribution.sort(key=lambda x: x["probability"], reverse=True)
872
+
873
+ # Update ranks based on probability order
874
+ for i, item in enumerate(prob_distribution):
875
+ item["probability_rank"] = i + 1
876
+
877
+ prob_data = {
878
+ "probabilities": prob_distribution,
879
+ "temperature": temperature,
880
+ "difficulty": difficulty,
881
+ "total_candidates": len(candidates),
882
+ "selected_count": len(selected_candidates)
883
+ }
884
+
885
+ return selected_candidates, prob_data
886
 
887
  def _detect_multiple_themes(self, inputs: List[str], max_themes: int = 3) -> List[np.ndarray]:
888
  """Detect multiple themes using clustering."""
 
1204
  final_words = []
1205
 
1206
  # Select words using either softmax weighted selection or traditional random selection
1207
+ probability_data = None
1208
  if self.use_softmax_selection:
1209
  logger.info(f"🎲 Using softmax weighted selection on all {len(candidate_words)} candidates (temperature: {self.similarity_temperature})")
1210
 
1211
  # Apply softmax selection to ALL candidate words regardless of clue quality
1212
  if len(candidate_words) > requested_words:
1213
+ selected_words, probability_data = self._softmax_weighted_selection(candidate_words, requested_words, difficulty=difficulty)
1214
  final_words.extend(selected_words)
1215
  else:
1216
  final_words.extend(candidate_words) # Take all words if not enough
 
1281
  for word_data in final_words
1282
  ]
1283
  }
1284
+
1285
+ # Add probability distribution data if available
1286
+ if probability_data:
1287
+ debug_data["probability_distribution"] = probability_data
1288
+
1289
  result["debug"] = debug_data
1290
  logger.info(f"🐛 Debug data collected: {len(debug_data['thematic_pool'])} thematic words, {len(debug_data['candidate_words'])} candidates, {len(debug_data['selected_words'])} selected")
1291
 
crossword-app/frontend/package-lock.json CHANGED
@@ -8,7 +8,10 @@
8
  "name": "crossword-frontend",
9
  "version": "1.0.0",
10
  "dependencies": {
 
 
11
  "react": "^18.2.0",
 
12
  "react-dom": "^18.2.0"
13
  },
14
  "devDependencies": {
@@ -850,6 +853,12 @@
850
  "@jridgewell/sourcemap-codec": "^1.4.14"
851
  }
852
  },
 
 
 
 
 
 
853
  "node_modules/@nodelib/fs.scandir": {
854
  "version": "2.1.5",
855
  "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1669,6 +1678,27 @@
1669
  "url": "https://github.com/chalk/chalk?sponsor=1"
1670
  }
1671
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1672
  "node_modules/color-convert": {
1673
  "version": "2.0.1",
1674
  "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3816,6 +3846,16 @@
3816
  "node": ">=0.10.0"
3817
  }
3818
  },
 
 
 
 
 
 
 
 
 
 
3819
  "node_modules/react-dom": {
3820
  "version": "18.3.1",
3821
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
 
8
  "name": "crossword-frontend",
9
  "version": "1.0.0",
10
  "dependencies": {
11
+ "chart.js": "^4.5.0",
12
+ "chartjs-plugin-annotation": "^3.1.0",
13
  "react": "^18.2.0",
14
+ "react-chartjs-2": "^5.3.0",
15
  "react-dom": "^18.2.0"
16
  },
17
  "devDependencies": {
 
853
  "@jridgewell/sourcemap-codec": "^1.4.14"
854
  }
855
  },
856
+ "node_modules/@kurkle/color": {
857
+ "version": "0.3.4",
858
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
859
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
860
+ "license": "MIT"
861
+ },
862
  "node_modules/@nodelib/fs.scandir": {
863
  "version": "2.1.5",
864
  "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
 
1678
  "url": "https://github.com/chalk/chalk?sponsor=1"
1679
  }
1680
  },
1681
+ "node_modules/chart.js": {
1682
+ "version": "4.5.0",
1683
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
1684
+ "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
1685
+ "license": "MIT",
1686
+ "dependencies": {
1687
+ "@kurkle/color": "^0.3.0"
1688
+ },
1689
+ "engines": {
1690
+ "pnpm": ">=8"
1691
+ }
1692
+ },
1693
+ "node_modules/chartjs-plugin-annotation": {
1694
+ "version": "3.1.0",
1695
+ "resolved": "https://registry.npmjs.org/chartjs-plugin-annotation/-/chartjs-plugin-annotation-3.1.0.tgz",
1696
+ "integrity": "sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==",
1697
+ "license": "MIT",
1698
+ "peerDependencies": {
1699
+ "chart.js": ">=4.0.0"
1700
+ }
1701
+ },
1702
  "node_modules/color-convert": {
1703
  "version": "2.0.1",
1704
  "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
 
3846
  "node": ">=0.10.0"
3847
  }
3848
  },
3849
+ "node_modules/react-chartjs-2": {
3850
+ "version": "5.3.0",
3851
+ "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
3852
+ "integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
3853
+ "license": "MIT",
3854
+ "peerDependencies": {
3855
+ "chart.js": "^4.1.1",
3856
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
3857
+ }
3858
+ },
3859
  "node_modules/react-dom": {
3860
  "version": "18.3.1",
3861
  "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
crossword-app/frontend/package.json CHANGED
@@ -13,7 +13,10 @@
13
  "format": "prettier --write \"src/**/*.{js,jsx,css,md}\""
14
  },
15
  "dependencies": {
 
 
16
  "react": "^18.2.0",
 
17
  "react-dom": "^18.2.0"
18
  },
19
  "devDependencies": {
@@ -39,4 +42,4 @@
39
  "last 1 safari version"
40
  ]
41
  }
42
- }
 
13
  "format": "prettier --write \"src/**/*.{js,jsx,css,md}\""
14
  },
15
  "dependencies": {
16
+ "chart.js": "^4.5.0",
17
+ "chartjs-plugin-annotation": "^3.1.0",
18
  "react": "^18.2.0",
19
+ "react-chartjs-2": "^5.3.0",
20
  "react-dom": "^18.2.0"
21
  },
22
  "devDependencies": {
 
42
  "last 1 safari version"
43
  ]
44
  }
45
+ }
crossword-app/frontend/src/components/DebugTab.jsx CHANGED
@@ -1,4 +1,26 @@
1
  import React, { useState } from 'react';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  const DebugTab = ({ debugData }) => {
4
  const [activeSection, setActiveSection] = useState('overview');
@@ -18,6 +40,7 @@ const DebugTab = ({ debugData }) => {
18
  { id: 'thematic-pool', label: 'Thematic Pool' },
19
  { id: 'candidates', label: 'Candidates' },
20
  { id: 'selection', label: 'Selection' },
 
21
  { id: 'selected', label: 'Selected Words' }
22
  ];
23
 
@@ -218,6 +241,339 @@ const DebugTab = ({ debugData }) => {
218
  </div>
219
  );
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  const renderSelected = () => {
222
  const selected = debugData.selected_words || [];
223
 
@@ -236,6 +592,7 @@ const DebugTab = ({ debugData }) => {
236
  case 'thematic-pool': return renderThematicPool();
237
  case 'candidates': return renderCandidates();
238
  case 'selection': return renderSelection();
 
239
  case 'selected': return renderSelected();
240
  default: return renderOverview();
241
  }
 
1
  import React, { useState } from 'react';
2
+ import {
3
+ Chart as ChartJS,
4
+ CategoryScale,
5
+ LinearScale,
6
+ BarElement,
7
+ Title,
8
+ Tooltip,
9
+ Legend,
10
+ } from 'chart.js';
11
+ import annotationPlugin from 'chartjs-plugin-annotation';
12
+ import { Bar } from 'react-chartjs-2';
13
+
14
+ // Register Chart.js components
15
+ ChartJS.register(
16
+ CategoryScale,
17
+ LinearScale,
18
+ BarElement,
19
+ Title,
20
+ Tooltip,
21
+ Legend,
22
+ annotationPlugin
23
+ );
24
 
25
  const DebugTab = ({ debugData }) => {
26
  const [activeSection, setActiveSection] = useState('overview');
 
40
  { id: 'thematic-pool', label: 'Thematic Pool' },
41
  { id: 'candidates', label: 'Candidates' },
42
  { id: 'selection', label: 'Selection' },
43
+ { id: 'probabilities', label: 'Probabilities' },
44
  { id: 'selected', label: 'Selected Words' }
45
  ];
46
 
 
241
  </div>
242
  );
243
 
244
+ const renderProbabilities = () => {
245
+ const probData = debugData.probability_distribution;
246
+
247
+ if (!probData || !probData.probabilities) {
248
+ return (
249
+ <div className="debug-section">
250
+ <h3>Probability Distribution</h3>
251
+ <p>Probability data not available (only shown with softmax selection).</p>
252
+ </div>
253
+ );
254
+ }
255
+
256
+ try {
257
+ const probabilities = probData.probabilities;
258
+
259
+ // Sort by percentile (descending) to show 100% -> 0% left to right
260
+ const sortedByPercentile = [...probabilities].sort((a, b) => b.percentile - a.percentile);
261
+
262
+ // Calculate distribution statistics based on position in sorted array
263
+ const mean = sortedByPercentile.reduce((sum, p, i) => sum + (p.probability || 0) * i, 0);
264
+ const variance = sortedByPercentile.reduce((sum, p, i) => sum + (p.probability || 0) * Math.pow(i - mean, 2), 0);
265
+ const sigma = Math.sqrt(Math.max(0, variance)); // Ensure no negative variance
266
+ const meanWordIndex = Math.max(0, Math.min(sortedByPercentile.length - 1, Math.round(mean)));
267
+ const sigmaRangeStart = Math.max(0, Math.round(mean - sigma));
268
+ const sigmaRangeEnd = Math.min(sortedByPercentile.length - 1, Math.round(mean + sigma));
269
+
270
+ // Calculate sampling statistics with bounds checking
271
+ const sigmaRangeProbMass = sortedByPercentile
272
+ .slice(sigmaRangeStart, sigmaRangeEnd + 1)
273
+ .reduce((sum, p) => sum + (p.probability || 0), 0);
274
+
275
+ // Prepare chart data - sorted by percentile to reveal Gaussian targeting
276
+ const chartData = {
277
+ labels: sortedByPercentile.map(p => `${p.word}\n(${(p.percentile * 100).toFixed(0)}%)`),
278
+ datasets: [
279
+ {
280
+ label: 'Selection Probability (%)',
281
+ data: sortedByPercentile.map(p => p.probability * 100),
282
+ backgroundColor: sortedByPercentile.map(p =>
283
+ p.selected ? 'rgba(76, 175, 80, 0.8)' : 'rgba(158, 158, 158, 0.6)'
284
+ ),
285
+ borderColor: sortedByPercentile.map(p =>
286
+ p.selected ? 'rgba(76, 175, 80, 1)' : 'rgba(158, 158, 158, 0.8)'
287
+ ),
288
+ borderWidth: 2
289
+ }
290
+ ]
291
+ };
292
+
293
+ const chartOptions = {
294
+ responsive: true,
295
+ maintainAspectRatio: false,
296
+ plugins: {
297
+ legend: {
298
+ display: false
299
+ },
300
+ title: {
301
+ display: true,
302
+ text: `Probability Distribution by Frequency Percentile (Temperature: ${probData.temperature})`,
303
+ font: {
304
+ size: 16,
305
+ weight: 'bold'
306
+ }
307
+ },
308
+ tooltip: {
309
+ callbacks: {
310
+ title: function(context) {
311
+ const item = sortedByPercentile[context[0].dataIndex];
312
+ return `${item.word} ${item.selected ? '✓ SELECTED' : ''}`;
313
+ },
314
+ label: function(context) {
315
+ const item = sortedByPercentile[context.dataIndex];
316
+ return [
317
+ `Probability: ${(item.probability * 100).toFixed(2)}%`,
318
+ `Composite Score: ${item.composite_score.toFixed(3)}`,
319
+ `Similarity: ${item.similarity.toFixed(3)}`,
320
+ `Percentile: ${(item.percentile * 100).toFixed(1)}%`,
321
+ `Tier: ${item.tier.replace('tier_', '').replace('_', ' ')}`
322
+ ];
323
+ }
324
+ },
325
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
326
+ titleColor: 'white',
327
+ bodyColor: 'white',
328
+ borderColor: 'rgba(255, 255, 255, 0.3)',
329
+ borderWidth: 1
330
+ }
331
+ },
332
+ scales: {
333
+ x: {
334
+ title: {
335
+ display: true,
336
+ text: 'Words (sorted by frequency percentile: 100% → 0%)',
337
+ font: {
338
+ size: 14,
339
+ weight: 'bold'
340
+ }
341
+ },
342
+ ticks: {
343
+ maxRotation: 45,
344
+ minRotation: 45,
345
+ font: {
346
+ size: 11,
347
+ weight: 'bold'
348
+ }
349
+ }
350
+ },
351
+ y: {
352
+ title: {
353
+ display: true,
354
+ text: 'Selection Probability (%)',
355
+ font: {
356
+ size: 14,
357
+ weight: 'bold'
358
+ }
359
+ },
360
+ beginAtZero: true,
361
+ ticks: {
362
+ callback: function(value) {
363
+ return value.toFixed(1) + '%';
364
+ }
365
+ }
366
+ }
367
+ },
368
+ interaction: {
369
+ intersect: false,
370
+ mode: 'index'
371
+ }
372
+ };
373
+
374
+ // Configure all plugins including annotation
375
+ const chartOptionsWithAnnotations = {
376
+ ...chartOptions,
377
+ plugins: {
378
+ legend: {
379
+ display: false
380
+ },
381
+ title: {
382
+ display: true,
383
+ text: `Probability Distribution by Frequency Percentile (Temperature: ${probData.temperature})`,
384
+ font: {
385
+ size: 16,
386
+ weight: 'bold'
387
+ }
388
+ },
389
+ tooltip: {
390
+ callbacks: {
391
+ title: function(context) {
392
+ const item = sortedByPercentile[context[0].dataIndex];
393
+ return `${item.word} ${item.selected ? '✓ SELECTED' : ''}`;
394
+ },
395
+ label: function(context) {
396
+ const item = sortedByPercentile[context.dataIndex];
397
+ return [
398
+ `Probability: ${(item.probability * 100).toFixed(2)}%`,
399
+ `Composite Score: ${item.composite_score.toFixed(3)}`,
400
+ `Similarity: ${item.similarity.toFixed(3)}`,
401
+ `Percentile: ${(item.percentile * 100).toFixed(1)}%`,
402
+ `Tier: ${item.tier.replace('tier_', '').replace('_', ' ')}`
403
+ ];
404
+ }
405
+ },
406
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
407
+ titleColor: 'white',
408
+ bodyColor: 'white',
409
+ borderColor: 'rgba(255, 255, 255, 0.3)',
410
+ borderWidth: 1
411
+ },
412
+ annotation: {
413
+ annotations: {
414
+ meanLine: {
415
+ type: 'line',
416
+ xMin: meanWordIndex,
417
+ xMax: meanWordIndex,
418
+ borderColor: 'rgba(255, 99, 132, 0.8)',
419
+ borderWidth: 3,
420
+ borderDash: [5, 5],
421
+ label: {
422
+ display: true,
423
+ content: 'μ',
424
+ position: 'start',
425
+ backgroundColor: 'rgba(255, 99, 132, 0.8)',
426
+ color: 'white',
427
+ font: {
428
+ weight: 'bold',
429
+ size: 12
430
+ }
431
+ }
432
+ },
433
+ sigmaBox: {
434
+ type: 'box',
435
+ xMin: sigmaRangeStart,
436
+ xMax: sigmaRangeEnd,
437
+ backgroundColor: 'rgba(54, 162, 235, 0.15)',
438
+ borderColor: 'rgba(54, 162, 235, 0.5)',
439
+ borderWidth: 2,
440
+ label: {
441
+ display: true,
442
+ content: `σ (${(sigmaRangeProbMass * 100).toFixed(1)}%)`,
443
+ position: 'center',
444
+ backgroundColor: 'rgba(54, 162, 235, 0.8)',
445
+ color: 'white',
446
+ font: {
447
+ weight: 'bold',
448
+ size: 11
449
+ }
450
+ }
451
+ },
452
+ sigmaStartLine: {
453
+ type: 'line',
454
+ xMin: sigmaRangeStart,
455
+ xMax: sigmaRangeStart,
456
+ borderColor: 'rgba(54, 162, 235, 0.8)',
457
+ borderWidth: 2,
458
+ borderDash: [3, 3],
459
+ label: {
460
+ display: true,
461
+ content: 'μ-σ',
462
+ position: 'start',
463
+ backgroundColor: 'rgba(54, 162, 235, 0.6)',
464
+ color: 'white',
465
+ font: {
466
+ size: 10
467
+ }
468
+ }
469
+ },
470
+ sigmaEndLine: {
471
+ type: 'line',
472
+ xMin: sigmaRangeEnd,
473
+ xMax: sigmaRangeEnd,
474
+ borderColor: 'rgba(54, 162, 235, 0.8)',
475
+ borderWidth: 2,
476
+ borderDash: [3, 3],
477
+ label: {
478
+ display: true,
479
+ content: 'μ+σ',
480
+ position: 'start',
481
+ backgroundColor: 'rgba(54, 162, 235, 0.6)',
482
+ color: 'white',
483
+ font: {
484
+ size: 10
485
+ }
486
+ }
487
+ }
488
+ }
489
+ }
490
+ }
491
+ };
492
+
493
+ return (
494
+ <div className="debug-section">
495
+ <h3>Probability Distribution ({probData.total_candidates} candidates)</h3>
496
+ <p>Selection probabilities from softmax algorithm (temperature: {probData.temperature}, difficulty: {probData.difficulty})</p>
497
+
498
+ <div className="prob-summary">
499
+ <div><strong>Selected:</strong> {probData.selected_count} words</div>
500
+ <div><strong>Top Probability:</strong> {(Math.max(...sortedByPercentile.map(p => p.probability)) * 100).toFixed(1)}%</div>
501
+ <div><strong>Average:</strong> {((1/probData.total_candidates) * 100).toFixed(1)}%</div>
502
+ <div><strong>Temperature Effect:</strong> {probData.temperature < 1 ? 'More deterministic' : probData.temperature > 1 ? 'More random' : 'Balanced'}</div>
503
+ <div><strong>Mean Position:</strong> Word #{meanWordIndex + 1} ({sortedByPercentile[meanWordIndex]?.word})</div>
504
+ <div><strong>Distribution Width (σ):</strong> {sigma.toFixed(1)} words</div>
505
+ <div><strong>σ Sampling Zone:</strong> {(sigmaRangeProbMass * 100).toFixed(1)}% of probability mass</div>
506
+ <div><strong>σ Range:</strong> Words #{sigmaRangeStart + 1}-#{sigmaRangeEnd + 1}</div>
507
+ </div>
508
+
509
+ {/* Interactive Bar Chart */}
510
+ <div className="chart-container">
511
+ <div style={{ height: '500px', marginBottom: '20px' }}>
512
+ <Bar data={chartData} options={chartOptionsWithAnnotations} />
513
+ </div>
514
+ <p className="chart-description">
515
+ <strong>📊 Frequency-Based Analysis:</strong> This chart shows ALL {probData.total_candidates} candidate words sorted by
516
+ frequency percentile (100% → 0%, common → rare). This reveals whether the Gaussian frequency targeting
517
+ is working correctly for your selected difficulty level. Look for probability peaks at the intended percentile ranges:
518
+ <strong> Easy (90%+), Medium (50%), Hard (20%)</strong>.
519
+ </p>
520
+ </div>
521
+
522
+ {/* Detailed Table */}
523
+ <h4>Detailed Probability Data</h4>
524
+ <div className="probability-table-container">
525
+ <table className="probability-table">
526
+ <thead>
527
+ <tr>
528
+ <th>Rank</th>
529
+ <th>Word</th>
530
+ <th>Probability</th>
531
+ <th>Composite</th>
532
+ <th>Similarity</th>
533
+ <th>Percentile</th>
534
+ <th>Selected</th>
535
+ </tr>
536
+ </thead>
537
+ <tbody>
538
+ {sortedByPercentile.map((item, idx) => (
539
+ <tr key={idx} className={item.selected ? 'selected-word' : ''}>
540
+ <td>{item.probability_rank}</td>
541
+ <td><strong>{item.word}</strong></td>
542
+ <td>
543
+ <div className="probability-cell">
544
+ <span className="prob-text">{(item.probability * 100).toFixed(2)}%</span>
545
+ <div
546
+ className="prob-bar"
547
+ style={{
548
+ width: `${Math.max(2, item.probability * 100 * 2)}px`,
549
+ backgroundColor: item.selected ? '#4CAF50' : '#e0e0e0'
550
+ }}
551
+ />
552
+ </div>
553
+ </td>
554
+ <td>{item.composite_score.toFixed(3)}</td>
555
+ <td>{item.similarity.toFixed(3)}</td>
556
+ <td>{item.percentile.toFixed(3)}</td>
557
+ <td>{item.selected ? '✓' : '✗'}</td>
558
+ </tr>
559
+ ))}
560
+ </tbody>
561
+ </table>
562
+ </div>
563
+ </div>
564
+ );
565
+ } catch (error) {
566
+ console.error('Error rendering probabilities:', error);
567
+ return (
568
+ <div className="debug-section">
569
+ <h3>Probability Distribution</h3>
570
+ <p style={{color: 'red'}}>Error rendering chart: {error.message}</p>
571
+ <p>Debug data available: {JSON.stringify(Object.keys(probData || {}))}</p>
572
+ </div>
573
+ );
574
+ }
575
+ };
576
+
577
  const renderSelected = () => {
578
  const selected = debugData.selected_words || [];
579
 
 
592
  case 'thematic-pool': return renderThematicPool();
593
  case 'candidates': return renderCandidates();
594
  case 'selection': return renderSelection();
595
+ case 'probabilities': return renderProbabilities();
596
  case 'selected': return renderSelected();
597
  default: return renderOverview();
598
  }
crossword-app/frontend/src/styles/puzzle.css CHANGED
@@ -720,6 +720,117 @@
720
  line-height: 1.4;
721
  }
722
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
723
  /* Responsive */
724
  @media (max-width: 768px) {
725
  .debug-nav {
@@ -743,4 +854,32 @@
743
  .word-table td {
744
  padding: 4px 8px;
745
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
746
  }
 
720
  line-height: 1.4;
721
  }
722
 
723
+ /* Probability Distribution Styling */
724
+ .prob-summary {
725
+ display: grid;
726
+ grid-template-columns: repeat(4, 1fr);
727
+ gap: 10px 15px;
728
+ margin: 15px 0 20px 0;
729
+ padding: 15px;
730
+ background: #f8f9fa;
731
+ border-radius: 8px;
732
+ font-size: 0.9rem;
733
+ }
734
+
735
+ .chart-container {
736
+ margin: 20px 0;
737
+ padding: 20px;
738
+ background: #ffffff;
739
+ border: 1px solid #dee2e6;
740
+ border-radius: 8px;
741
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
742
+ }
743
+
744
+ .chart-description {
745
+ background: #e3f2fd;
746
+ padding: 12px 15px;
747
+ border-radius: 6px;
748
+ border-left: 4px solid #1976d2;
749
+ margin-top: 15px;
750
+ font-size: 0.9rem;
751
+ line-height: 1.4;
752
+ color: #1565c0;
753
+ }
754
+
755
+ .probability-table-container {
756
+ max-height: 600px;
757
+ overflow-y: auto;
758
+ border: 1px solid #dee2e6;
759
+ border-radius: 8px;
760
+ }
761
+
762
+ .probability-table {
763
+ width: 100%;
764
+ border-collapse: collapse;
765
+ font-size: 0.9rem;
766
+ }
767
+
768
+ .probability-table th {
769
+ background: #495057;
770
+ color: white;
771
+ padding: 12px 8px;
772
+ text-align: left;
773
+ font-weight: 600;
774
+ position: sticky;
775
+ top: 0;
776
+ z-index: 10;
777
+ border-bottom: 2px solid #343a40;
778
+ }
779
+
780
+ .probability-table td {
781
+ padding: 8px;
782
+ border-bottom: 1px solid #e9ecef;
783
+ vertical-align: middle;
784
+ }
785
+
786
+ .probability-table tr:hover {
787
+ background: #f8f9fa;
788
+ }
789
+
790
+ .probability-table tr.selected-word {
791
+ background: #e8f5e8;
792
+ border-left: 4px solid #4CAF50;
793
+ }
794
+
795
+ .probability-table tr.selected-word:hover {
796
+ background: #d4edda;
797
+ }
798
+
799
+ .probability-cell {
800
+ display: flex;
801
+ align-items: center;
802
+ gap: 10px;
803
+ }
804
+
805
+ .prob-text {
806
+ min-width: 60px;
807
+ font-weight: 600;
808
+ }
809
+
810
+ .prob-bar {
811
+ height: 16px;
812
+ border-radius: 8px;
813
+ transition: all 0.3s ease;
814
+ min-width: 2px;
815
+ }
816
+
817
+ .probability-table td:first-child {
818
+ text-align: center;
819
+ color: #6c757d;
820
+ font-weight: 600;
821
+ }
822
+
823
+ .probability-table td:last-child {
824
+ text-align: center;
825
+ font-size: 1.1rem;
826
+ font-weight: bold;
827
+ color: #4CAF50;
828
+ }
829
+
830
+ .probability-table tr:not(.selected-word) td:last-child {
831
+ color: #f44336;
832
+ }
833
+
834
  /* Responsive */
835
  @media (max-width: 768px) {
836
  .debug-nav {
 
854
  .word-table td {
855
  padding: 4px 8px;
856
  }
857
+
858
+ .prob-summary {
859
+ grid-template-columns: repeat(2, 1fr);
860
+ text-align: center;
861
+ }
862
+
863
+ .chart-container {
864
+ padding: 10px;
865
+ margin: 10px 0;
866
+ }
867
+
868
+ .probability-table {
869
+ font-size: 0.75rem;
870
+ }
871
+
872
+ .probability-table th,
873
+ .probability-table td {
874
+ padding: 6px 4px;
875
+ }
876
+
877
+ .prob-text {
878
+ min-width: 50px;
879
+ font-size: 0.8rem;
880
+ }
881
+
882
+ .prob-bar {
883
+ height: 12px;
884
+ }
885
  }