@shaxpir/duiduidui-models 1.9.19 → 1.9.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,15 +13,6 @@ export interface CollectionDisplay {
13
13
  icon?: string;
14
14
  sort_order: number;
15
15
  }
16
- /**
17
- * A snapshot of a term's proficiency within this collection.
18
- * Stored in term_scores map, keyed by termId.
19
- */
20
- export interface CollectionTermScore {
21
- text: string;
22
- sense_rank: number;
23
- theta: number;
24
- }
25
16
  /**
26
17
  * The payload for a Collection document.
27
18
  *
@@ -34,7 +25,7 @@ export interface CollectionPayload {
34
25
  display: CollectionDisplay;
35
26
  proficiency: UncertainValue;
36
27
  term_scores: {
37
- [termId: string]: CollectionTermScore;
28
+ [termKey: string]: number;
38
29
  };
39
30
  total_cards: number;
40
31
  last_activity_utc: CompactDateTime | null;
@@ -61,6 +52,18 @@ export declare class Collection extends Content {
61
52
  * The same user + collectionKey always produces the same ID.
62
53
  */
63
54
  static makeCollectionId(userId: ContentId, collectionKey: string): ContentId;
55
+ /**
56
+ * Encode a term key from text and sense_rank.
57
+ * Uses the standard SenseRankEncoder format: "text [N+1]" or "text [radical]"
58
+ */
59
+ static encodeTermKey(text: string, senseRank: number): string;
60
+ /**
61
+ * Decode a term key back to text and sense_rank.
62
+ */
63
+ static decodeTermKey(termKey: string): {
64
+ text: string;
65
+ senseRank: number;
66
+ };
64
67
  constructor(doc: Doc, shouldAcquire: boolean, shareSync: ShareSync);
65
68
  /**
66
69
  * Create a new Collection for a user.
@@ -79,16 +82,20 @@ export declare class Collection extends Content {
79
82
  get proficiencyRatingDeviation(): number;
80
83
  get proficiencyVolatility(): number;
81
84
  get termScores(): {
82
- [termId: string]: CollectionTermScore;
85
+ [termKey: string]: number;
83
86
  };
84
87
  /**
85
88
  * Get the number of unique terms the user has reviewed in this collection.
86
89
  */
87
90
  get termsReviewedCount(): number;
88
91
  /**
89
- * Get the term score for a specific term, or undefined if not reviewed.
92
+ * Get the theta score for a specific term, or undefined if not reviewed.
93
+ */
94
+ getTermScore(text: string, senseRank: number): number | undefined;
95
+ /**
96
+ * Get the theta score by encoded term key, or undefined if not reviewed.
90
97
  */
91
- getTermScore(termId: string): CollectionTermScore | undefined;
98
+ getTermScoreByKey(termKey: string): number | undefined;
92
99
  get totalCards(): number;
93
100
  get lastActivityUtc(): CompactDateTime | null;
94
101
  get isVisible(): boolean;
@@ -124,16 +131,11 @@ export declare class Collection extends Content {
124
131
  /**
125
132
  * Update or add a term score in the collection.
126
133
  */
127
- setTermScore(termId: string, score: CollectionTermScore): void;
128
- /**
129
- * Update just the theta value for an existing term score.
130
- * Does nothing if the term doesn't exist in term_scores.
131
- */
132
- updateTermTheta(termId: string, theta: number): void;
134
+ setTermScore(text: string, senseRank: number, theta: number): void;
133
135
  /**
134
136
  * Remove a term score from the collection.
135
137
  */
136
- removeTermScore(termId: string): void;
138
+ removeTermScore(text: string, senseRank: number): void;
137
139
  /**
138
140
  * Update the cached total card count for this collection.
139
141
  */
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Collection = void 0;
4
4
  const shaxpir_common_1 = require("@shaxpir/shaxpir-common");
5
5
  const repo_1 = require("../repo");
6
+ const SenseRankEncoder_1 = require("../util/SenseRankEncoder");
6
7
  const Content_1 = require("./Content");
7
8
  const ContentKind_1 = require("./ContentKind");
8
9
  const Operation_1 = require("./Operation");
@@ -14,6 +15,19 @@ class Collection extends Content_1.Content {
14
15
  static makeCollectionId(userId, collectionKey) {
15
16
  return shaxpir_common_1.CachingHasher.makeMd5Base62Hash(`${ContentKind_1.ContentKind.COLLECTION}-${userId}-${collectionKey}`);
16
17
  }
18
+ /**
19
+ * Encode a term key from text and sense_rank.
20
+ * Uses the standard SenseRankEncoder format: "text [N+1]" or "text [radical]"
21
+ */
22
+ static encodeTermKey(text, senseRank) {
23
+ return (0, SenseRankEncoder_1.encodeSenseRank)(text, senseRank);
24
+ }
25
+ /**
26
+ * Decode a term key back to text and sense_rank.
27
+ */
28
+ static decodeTermKey(termKey) {
29
+ return (0, SenseRankEncoder_1.decodeSenseRank)(termKey);
30
+ }
17
31
  constructor(doc, shouldAcquire, shareSync) {
18
32
  super(doc, shouldAcquire, shareSync);
19
33
  }
@@ -131,11 +145,19 @@ class Collection extends Content_1.Content {
131
145
  return Object.keys(this.payload.term_scores).length;
132
146
  }
133
147
  /**
134
- * Get the term score for a specific term, or undefined if not reviewed.
148
+ * Get the theta score for a specific term, or undefined if not reviewed.
135
149
  */
136
- getTermScore(termId) {
150
+ getTermScore(text, senseRank) {
137
151
  this.checkDisposed("Collection.getTermScore");
138
- return this.payload.term_scores[termId];
152
+ const termKey = Collection.encodeTermKey(text, senseRank);
153
+ return this.payload.term_scores[termKey];
154
+ }
155
+ /**
156
+ * Get the theta score by encoded term key, or undefined if not reviewed.
157
+ */
158
+ getTermScoreByKey(termKey) {
159
+ this.checkDisposed("Collection.getTermScoreByKey");
160
+ return this.payload.term_scores[termKey];
139
161
  }
140
162
  // ============================================================
141
163
  // Progress statistics getters
@@ -178,7 +200,7 @@ class Collection extends Content_1.Content {
178
200
  const scores = Object.values(this.payload.term_scores);
179
201
  if (scores.length === 0)
180
202
  return 0;
181
- const mastered = scores.filter(s => s.theta >= 0.8).length;
203
+ const mastered = scores.filter(theta => theta >= 0.8).length;
182
204
  return Math.round((mastered / scores.length) * 100);
183
205
  }
184
206
  /**
@@ -199,11 +221,11 @@ class Collection extends Content_1.Content {
199
221
  this.checkDisposed("Collection.getGradeDistribution");
200
222
  const scores = Object.values(this.payload.term_scores);
201
223
  return {
202
- A: scores.filter(s => s.theta >= 0.8).length,
203
- B: scores.filter(s => s.theta >= 0.6 && s.theta < 0.8).length,
204
- C: scores.filter(s => s.theta >= 0.4 && s.theta < 0.6).length,
205
- D: scores.filter(s => s.theta >= 0.2 && s.theta < 0.4).length,
206
- F: scores.filter(s => s.theta < 0.2).length
224
+ A: scores.filter(theta => theta >= 0.8).length,
225
+ B: scores.filter(theta => theta >= 0.6 && theta < 0.8).length,
226
+ C: scores.filter(theta => theta >= 0.4 && theta < 0.6).length,
227
+ D: scores.filter(theta => theta >= 0.2 && theta < 0.4).length,
228
+ F: scores.filter(theta => theta < 0.2).length
207
229
  };
208
230
  }
209
231
  // ============================================================
@@ -225,32 +247,22 @@ class Collection extends Content_1.Content {
225
247
  /**
226
248
  * Update or add a term score in the collection.
227
249
  */
228
- setTermScore(termId, score) {
250
+ setTermScore(text, senseRank, theta) {
229
251
  this.checkDisposed("Collection.setTermScore");
252
+ const termKey = Collection.encodeTermKey(text, senseRank);
230
253
  const batch = new Operation_1.BatchOperation(this);
231
- batch.setPathValue(['payload', 'term_scores', termId], score);
254
+ batch.setPathValue(['payload', 'term_scores', termKey], theta);
232
255
  batch.commit();
233
256
  }
234
- /**
235
- * Update just the theta value for an existing term score.
236
- * Does nothing if the term doesn't exist in term_scores.
237
- */
238
- updateTermTheta(termId, theta) {
239
- this.checkDisposed("Collection.updateTermTheta");
240
- if (this.payload.term_scores[termId]) {
241
- const batch = new Operation_1.BatchOperation(this);
242
- batch.setPathValue(['payload', 'term_scores', termId, 'theta'], theta);
243
- batch.commit();
244
- }
245
- }
246
257
  /**
247
258
  * Remove a term score from the collection.
248
259
  */
249
- removeTermScore(termId) {
260
+ removeTermScore(text, senseRank) {
250
261
  this.checkDisposed("Collection.removeTermScore");
251
- if (this.payload.term_scores[termId]) {
262
+ const termKey = Collection.encodeTermKey(text, senseRank);
263
+ if (this.payload.term_scores[termKey] !== undefined) {
252
264
  const batch = new Operation_1.BatchOperation(this);
253
- batch.removeValueAtPath(['payload', 'term_scores', termId]);
265
+ batch.removeValueAtPath(['payload', 'term_scores', termKey]);
254
266
  batch.commit();
255
267
  }
256
268
  }
@@ -1,72 +1,146 @@
1
1
  import { ReviewResult } from './Review';
2
2
  import { Bounds } from './BayesianScore';
3
3
  /**
4
- * Represents a skill level rating with uncertainty bounds.
5
- * Uses the Glicko-2 rating system for skill assessment.
4
+ * Represents a skill level estimate with uncertainty.
5
+ * Uses Item Response Theory (IRT) for skill assessment.
6
+ *
7
+ * The skill level represents the estimated number of characters
8
+ * a user has mastered. A user with skill level N is expected to
9
+ * succeed on recognition tasks for characters with difficulty <= N.
6
10
  */
7
11
  export interface SkillLevel {
12
+ /** Estimated skill level (maps to ~number of characters mastered) */
13
+ mu: number;
14
+ /** Uncertainty in the estimate (standard deviation) */
15
+ sigma: number;
16
+ }
17
+ /**
18
+ * Legacy interface for backwards compatibility during migration.
19
+ * Maps to the new SkillLevel interface.
20
+ * @deprecated Use SkillLevel with mu/sigma instead
21
+ */
22
+ export interface LegacySkillLevel {
8
23
  rating: number;
9
24
  rating_deviation: number;
10
25
  volatility: number;
11
26
  }
12
27
  /**
13
- * Model for updating and managing skill levels using the Glicko-2 rating system.
28
+ * Model for updating and managing skill levels using Item Response Theory (IRT).
14
29
  *
15
- * Unlike traditional Glicko-2 which uses a 1500-centered scale for chess,
16
- * this adapts the algorithm to DuiDuiDui's semantic scale where:
17
- * - Rating 0 = absolute beginner (knows ~0 characters)
18
- * - Rating 100 = knows ~100 characters
19
- * - Rating 3000 = knows ~3000 characters
30
+ * This model estimates user skill based on a logistic psychometric function:
31
+ * P(success | difficulty d, skill μ) = 1 / (1 + exp(k * (d - μ)))
20
32
  *
21
- * The Glicko-2 algorithm is used without the typical scaling transformations,
22
- * working directly on our 0-10000 absolute scale.
33
+ * Where:
34
+ * - μ (mu) is the skill level we're estimating
35
+ * - k controls the steepness of the transition from "easy" to "hard"
36
+ * - Cards with difficulty < μ are likely to be answered correctly
37
+ * - Cards with difficulty > μ are likely to be answered incorrectly
38
+ *
39
+ * The model uses Bayesian updating with a Gaussian approximation:
40
+ * - Each observation updates μ based on prediction error
41
+ * - Uncertainty (σ) decreases as we accumulate evidence
42
+ * - No full history is needed - just μ and σ are sufficient statistics
43
+ *
44
+ * Scale semantics:
45
+ * - μ = 0: absolute beginner (knows ~0 characters)
46
+ * - μ = 100: knows ~100 characters
47
+ * - μ = 3000: knows ~3000 characters
23
48
  */
24
49
  export declare class SkillLevelModel {
25
50
  /**
26
- * Update skill level using the Glicko-2 algorithm after a review.
51
+ * Steepness parameter for the logistic function.
52
+ *
53
+ * Controls how sharply success probability drops as difficulty exceeds skill:
54
+ * - k = 0.05 means the transition zone spans ~40 difficulty points
55
+ * - At difficulty = μ, P(success) = 50%
56
+ * - At difficulty = μ + 20, P(success) ≈ 27%
57
+ * - At difficulty = μ + 40, P(success) ≈ 12%
58
+ *
59
+ * Lower k = more gradual transition, higher k = sharper cutoff.
60
+ */
61
+ private static readonly K;
62
+ /**
63
+ * Minimum sigma value to prevent over-confidence.
64
+ * Even with many observations, we maintain some uncertainty.
65
+ */
66
+ private static readonly MIN_SIGMA;
67
+ /**
68
+ * Maximum sigma value for initial state.
69
+ */
70
+ private static readonly MAX_SIGMA;
71
+ /**
72
+ * Update skill level using IRT after a review.
27
73
  *
28
- * @param rating Current skill level rating
29
- * @param ratingDeviation Current rating deviation (uncertainty)
30
- * @param volatility Current volatility (performance consistency)
31
- * @param cardDifficulty Difficulty of the card reviewed (on same scale as rating)
32
- * @param cardRatingDeviation Rating deviation for the card (typically fixed, e.g., 100)
74
+ * Uses Bayesian updating with a Gaussian approximation to the posterior.
75
+ * The update is incremental - only requires current μ and σ, not full history.
76
+ *
77
+ * @param mu Current skill level estimate
78
+ * @param sigma Current uncertainty (standard deviation)
79
+ * @param cardDifficulty Difficulty of the card reviewed
33
80
  * @param outcome Review result (FAIL/HARD/GOOD/EASY)
34
- * @param tau System constant controlling volatility changes (default 0.5)
35
- * @returns Updated skill level with new rating, rating_deviation, and volatility
81
+ * @returns Updated skill level with new mu and sigma
36
82
  */
37
- static updateWithGlicko2(rating: number, ratingDeviation: number, volatility: number, cardDifficulty: number, cardRatingDeviation: number, outcome: ReviewResult, tau?: number): SkillLevel;
83
+ static update(mu: number, sigma: number, cardDifficulty: number, outcome: ReviewResult): SkillLevel;
38
84
  /**
39
- * Map a review outcome to a numeric score for Glicko-2.
85
+ * Update skill level using IRT (Glicko-2 compatible signature).
86
+ *
87
+ * This method provides backwards compatibility with the old Glicko-2 API.
88
+ * The volatility and cardRatingDeviation parameters are ignored.
89
+ *
90
+ * @deprecated Use update() instead for cleaner IRT semantics
91
+ */
92
+ static updateWithGlicko2(rating: number, ratingDeviation: number, _volatility: number, cardDifficulty: number, _cardRatingDeviation: number, outcome: ReviewResult, _tau?: number): LegacySkillLevel;
93
+ /**
94
+ * Map a review outcome to a success score for IRT.
40
95
  *
41
96
  * @param outcome Review result
42
- * @returns Numeric score from 0.0 (complete failure) to 1.0 (perfect success)
97
+ * @returns Success score from 0.0 (complete failure) to 1.0 (perfect success)
43
98
  */
44
99
  static outcomeToScore(outcome: ReviewResult): number;
45
100
  /**
46
- * Calculate confidence bounds from Glicko-2 parameters.
47
- * Returns a proper statistical confidence interval.
101
+ * Calculate the probability of success on a card given skill level.
102
+ *
103
+ * @param mu Skill level estimate
104
+ * @param cardDifficulty Difficulty of the card
105
+ * @returns Probability of success (0 to 1)
106
+ */
107
+ static predictSuccess(mu: number, cardDifficulty: number): number;
108
+ /**
109
+ * Calculate confidence bounds for the skill estimate.
48
110
  *
49
- * @param rating Current skill level rating
50
- * @param ratingDeviation Rating deviation (standard deviation of rating)
111
+ * @param mu Skill level estimate
112
+ * @param sigma Uncertainty (standard deviation)
51
113
  * @param confidence Confidence level (0.95 for 95%, 0.99 for 99%)
52
114
  * @returns Bounds object with lower and upper confidence limits
53
115
  */
54
- static calculateBounds(rating: number, ratingDeviation: number, confidence?: number): Bounds;
116
+ static calculateBounds(mu: number, sigma: number, confidence?: number): Bounds;
55
117
  /**
56
118
  * Create a default skill level for a new user.
57
- * Starts at rating 0 (knows no characters) with high uncertainty.
119
+ * Starts at skill 0 (knows no characters) with high uncertainty.
58
120
  *
59
- * @param initialRatingDeviation Initial rating deviation (default 50, meaning ±100 char uncertainty at 95% CI)
60
- * @param initialVolatility Initial volatility (default 0.06, moderate)
121
+ * @param initialSigma Initial uncertainty (default 50)
61
122
  * @returns New SkillLevel object
62
123
  */
63
- static createDefault(initialRatingDeviation?: number, initialVolatility?: number): SkillLevel;
124
+ static createDefault(initialSigma?: number): SkillLevel;
125
+ /**
126
+ * Create a default skill level in legacy format.
127
+ * @deprecated Use createDefault() instead
128
+ */
129
+ static createDefaultLegacy(initialRatingDeviation?: number, initialVolatility?: number): LegacySkillLevel;
130
+ /**
131
+ * Convert from legacy Glicko-2 format to IRT format.
132
+ */
133
+ static fromLegacy(legacy: LegacySkillLevel): SkillLevel;
134
+ /**
135
+ * Convert from IRT format to legacy Glicko-2 format.
136
+ */
137
+ static toLegacy(skill: SkillLevel): LegacySkillLevel;
64
138
  /**
65
139
  * Check if the skill level has high confidence (low uncertainty).
66
140
  *
67
- * @param ratingDeviation Rating deviation
68
- * @param threshold Maximum rating deviation to be considered "high confidence" (default 15)
69
- * @returns True if uncertainty is low (rating deviation below threshold)
141
+ * @param sigma Uncertainty (standard deviation)
142
+ * @param threshold Maximum sigma to be considered "high confidence" (default 15)
143
+ * @returns True if uncertainty is low
70
144
  */
71
- static isHighConfidence(ratingDeviation: number, threshold?: number): boolean;
145
+ static isHighConfidence(sigma: number, threshold?: number): boolean;
72
146
  }
@@ -1,106 +1,192 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.SkillLevelModel = void 0;
7
- const glicko2_lite_1 = __importDefault(require("glicko2-lite"));
8
4
  /**
9
- * Model for updating and managing skill levels using the Glicko-2 rating system.
5
+ * Model for updating and managing skill levels using Item Response Theory (IRT).
10
6
  *
11
- * Unlike traditional Glicko-2 which uses a 1500-centered scale for chess,
12
- * this adapts the algorithm to DuiDuiDui's semantic scale where:
13
- * - Rating 0 = absolute beginner (knows ~0 characters)
14
- * - Rating 100 = knows ~100 characters
15
- * - Rating 3000 = knows ~3000 characters
7
+ * This model estimates user skill based on a logistic psychometric function:
8
+ * P(success | difficulty d, skill μ) = 1 / (1 + exp(k * (d - μ)))
16
9
  *
17
- * The Glicko-2 algorithm is used without the typical scaling transformations,
18
- * working directly on our 0-10000 absolute scale.
10
+ * Where:
11
+ * - μ (mu) is the skill level we're estimating
12
+ * - k controls the steepness of the transition from "easy" to "hard"
13
+ * - Cards with difficulty < μ are likely to be answered correctly
14
+ * - Cards with difficulty > μ are likely to be answered incorrectly
15
+ *
16
+ * The model uses Bayesian updating with a Gaussian approximation:
17
+ * - Each observation updates μ based on prediction error
18
+ * - Uncertainty (σ) decreases as we accumulate evidence
19
+ * - No full history is needed - just μ and σ are sufficient statistics
20
+ *
21
+ * Scale semantics:
22
+ * - μ = 0: absolute beginner (knows ~0 characters)
23
+ * - μ = 100: knows ~100 characters
24
+ * - μ = 3000: knows ~3000 characters
19
25
  */
20
26
  class SkillLevelModel {
21
27
  /**
22
- * Update skill level using the Glicko-2 algorithm after a review.
28
+ * Update skill level using IRT after a review.
29
+ *
30
+ * Uses Bayesian updating with a Gaussian approximation to the posterior.
31
+ * The update is incremental - only requires current μ and σ, not full history.
23
32
  *
24
- * @param rating Current skill level rating
25
- * @param ratingDeviation Current rating deviation (uncertainty)
26
- * @param volatility Current volatility (performance consistency)
27
- * @param cardDifficulty Difficulty of the card reviewed (on same scale as rating)
28
- * @param cardRatingDeviation Rating deviation for the card (typically fixed, e.g., 100)
33
+ * @param mu Current skill level estimate
34
+ * @param sigma Current uncertainty (standard deviation)
35
+ * @param cardDifficulty Difficulty of the card reviewed
29
36
  * @param outcome Review result (FAIL/HARD/GOOD/EASY)
30
- * @param tau System constant controlling volatility changes (default 0.5)
31
- * @returns Updated skill level with new rating, rating_deviation, and volatility
37
+ * @returns Updated skill level with new mu and sigma
38
+ */
39
+ static update(mu, sigma, cardDifficulty, outcome) {
40
+ const k = SkillLevelModel.K;
41
+ // Map outcome to success value (0 to 1)
42
+ const y = SkillLevelModel.outcomeToScore(outcome);
43
+ // Predicted probability of success given current skill estimate
44
+ const p = 1 / (1 + Math.exp(k * (cardDifficulty - mu)));
45
+ // Prediction error: positive if did better than expected, negative if worse
46
+ const error = y - p;
47
+ // Fisher information for logistic model (highest at p = 0.5)
48
+ const info = p * (1 - p);
49
+ // Update variance (precision increases with information)
50
+ const sigmaSquared = sigma * sigma;
51
+ const newSigmaSquared = 1 / (1 / sigmaSquared + k * k * info);
52
+ const newSigma = Math.sqrt(newSigmaSquared);
53
+ // Update mean (shift toward evidence)
54
+ const newMu = mu + newSigmaSquared * k * error;
55
+ return {
56
+ mu: Math.max(0, newMu), // Skill can't go negative
57
+ sigma: Math.max(SkillLevelModel.MIN_SIGMA, Math.min(SkillLevelModel.MAX_SIGMA, newSigma))
58
+ };
59
+ }
60
+ /**
61
+ * Update skill level using IRT (Glicko-2 compatible signature).
62
+ *
63
+ * This method provides backwards compatibility with the old Glicko-2 API.
64
+ * The volatility and cardRatingDeviation parameters are ignored.
65
+ *
66
+ * @deprecated Use update() instead for cleaner IRT semantics
32
67
  */
33
- static updateWithGlicko2(rating, ratingDeviation, volatility, cardDifficulty, cardRatingDeviation, outcome, tau = 0.5) {
34
- // Map outcome to numeric score (0.0 to 1.0)
35
- const score = SkillLevelModel.outcomeToScore(outcome);
36
- // Call glicko2-lite with a single match
37
- // Match format: [opponentRating, opponentRD, outcome]
38
- const result = (0, glicko2_lite_1.default)(rating, ratingDeviation, volatility, [
39
- [cardDifficulty, cardRatingDeviation, score]
40
- ], { tau });
68
+ static updateWithGlicko2(rating, ratingDeviation, _volatility, cardDifficulty, _cardRatingDeviation, outcome, _tau = 0.5) {
69
+ const result = SkillLevelModel.update(rating, ratingDeviation, cardDifficulty, outcome);
70
+ // Return in legacy format
41
71
  return {
42
- rating: result.rating,
43
- rating_deviation: result.rd,
44
- volatility: result.vol
72
+ rating: result.mu,
73
+ rating_deviation: result.sigma,
74
+ volatility: 0.06 // Fixed value, not used in IRT
45
75
  };
46
76
  }
47
77
  /**
48
- * Map a review outcome to a numeric score for Glicko-2.
78
+ * Map a review outcome to a success score for IRT.
49
79
  *
50
80
  * @param outcome Review result
51
- * @returns Numeric score from 0.0 (complete failure) to 1.0 (perfect success)
81
+ * @returns Success score from 0.0 (complete failure) to 1.0 (perfect success)
52
82
  */
53
83
  static outcomeToScore(outcome) {
54
84
  switch (outcome) {
55
85
  case 'EASY': return 1.0;
56
- case 'GOOD': return 0.67;
57
- case 'HARD': return 0.33;
86
+ case 'GOOD': return 0.85;
87
+ case 'HARD': return 0.3;
58
88
  case 'FAIL': return 0.0;
59
89
  }
60
90
  }
61
91
  /**
62
- * Calculate confidence bounds from Glicko-2 parameters.
63
- * Returns a proper statistical confidence interval.
92
+ * Calculate the probability of success on a card given skill level.
64
93
  *
65
- * @param rating Current skill level rating
66
- * @param ratingDeviation Rating deviation (standard deviation of rating)
94
+ * @param mu Skill level estimate
95
+ * @param cardDifficulty Difficulty of the card
96
+ * @returns Probability of success (0 to 1)
97
+ */
98
+ static predictSuccess(mu, cardDifficulty) {
99
+ return 1 / (1 + Math.exp(SkillLevelModel.K * (cardDifficulty - mu)));
100
+ }
101
+ /**
102
+ * Calculate confidence bounds for the skill estimate.
103
+ *
104
+ * @param mu Skill level estimate
105
+ * @param sigma Uncertainty (standard deviation)
67
106
  * @param confidence Confidence level (0.95 for 95%, 0.99 for 99%)
68
107
  * @returns Bounds object with lower and upper confidence limits
69
108
  */
70
- static calculateBounds(rating, ratingDeviation, confidence = 0.95) {
109
+ static calculateBounds(mu, sigma, confidence = 0.95) {
71
110
  // Z-score for desired confidence level
72
- // 95% CI: ±1.96 standard deviations
73
- // 99% CI: ±2.58 standard deviations
74
111
  const z = confidence === 0.95 ? 1.96 : confidence === 0.99 ? 2.58 : 1.96;
75
112
  return {
76
- lower: Math.max(0, rating - z * ratingDeviation),
77
- upper: Math.max(0, rating + z * ratingDeviation)
113
+ lower: Math.max(0, mu - z * sigma),
114
+ upper: mu + z * sigma
78
115
  };
79
116
  }
80
117
  /**
81
118
  * Create a default skill level for a new user.
82
- * Starts at rating 0 (knows no characters) with high uncertainty.
119
+ * Starts at skill 0 (knows no characters) with high uncertainty.
83
120
  *
84
- * @param initialRatingDeviation Initial rating deviation (default 50, meaning ±100 char uncertainty at 95% CI)
85
- * @param initialVolatility Initial volatility (default 0.06, moderate)
121
+ * @param initialSigma Initial uncertainty (default 50)
86
122
  * @returns New SkillLevel object
87
123
  */
88
- static createDefault(initialRatingDeviation = 50, initialVolatility = 0.06) {
124
+ static createDefault(initialSigma = 50) {
125
+ return {
126
+ mu: 0,
127
+ sigma: initialSigma
128
+ };
129
+ }
130
+ /**
131
+ * Create a default skill level in legacy format.
132
+ * @deprecated Use createDefault() instead
133
+ */
134
+ static createDefaultLegacy(initialRatingDeviation = 50, initialVolatility = 0.06) {
89
135
  return {
90
136
  rating: 0,
91
137
  rating_deviation: initialRatingDeviation,
92
138
  volatility: initialVolatility
93
139
  };
94
140
  }
141
+ /**
142
+ * Convert from legacy Glicko-2 format to IRT format.
143
+ */
144
+ static fromLegacy(legacy) {
145
+ return {
146
+ mu: legacy.rating,
147
+ sigma: legacy.rating_deviation
148
+ };
149
+ }
150
+ /**
151
+ * Convert from IRT format to legacy Glicko-2 format.
152
+ */
153
+ static toLegacy(skill) {
154
+ return {
155
+ rating: skill.mu,
156
+ rating_deviation: skill.sigma,
157
+ volatility: 0.06
158
+ };
159
+ }
95
160
  /**
96
161
  * Check if the skill level has high confidence (low uncertainty).
97
162
  *
98
- * @param ratingDeviation Rating deviation
99
- * @param threshold Maximum rating deviation to be considered "high confidence" (default 15)
100
- * @returns True if uncertainty is low (rating deviation below threshold)
163
+ * @param sigma Uncertainty (standard deviation)
164
+ * @param threshold Maximum sigma to be considered "high confidence" (default 15)
165
+ * @returns True if uncertainty is low
101
166
  */
102
- static isHighConfidence(ratingDeviation, threshold = 15) {
103
- return ratingDeviation < threshold;
167
+ static isHighConfidence(sigma, threshold = 15) {
168
+ return sigma < threshold;
104
169
  }
105
170
  }
106
171
  exports.SkillLevelModel = SkillLevelModel;
172
+ /**
173
+ * Steepness parameter for the logistic function.
174
+ *
175
+ * Controls how sharply success probability drops as difficulty exceeds skill:
176
+ * - k = 0.05 means the transition zone spans ~40 difficulty points
177
+ * - At difficulty = μ, P(success) = 50%
178
+ * - At difficulty = μ + 20, P(success) ≈ 27%
179
+ * - At difficulty = μ + 40, P(success) ≈ 12%
180
+ *
181
+ * Lower k = more gradual transition, higher k = sharper cutoff.
182
+ */
183
+ SkillLevelModel.K = 0.05;
184
+ /**
185
+ * Minimum sigma value to prevent over-confidence.
186
+ * Even with many observations, we maintain some uncertainty.
187
+ */
188
+ SkillLevelModel.MIN_SIGMA = 5;
189
+ /**
190
+ * Maximum sigma value for initial state.
191
+ */
192
+ SkillLevelModel.MAX_SIGMA = 100;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Utilities for encoding and decoding sense_rank into search text.
3
+ *
4
+ * Encoding format (1-based for user display):
5
+ * - sense_rank >= 0: "text [N+1]" where N is the sense_rank (displayed as 1-based)
6
+ * - sense_rank = -1: "text [radical]"
7
+ *
8
+ * Examples:
9
+ * - "王 [1]" -> sense_rank: 0 (displayed as 1, stored as 0)
10
+ * - "王 [2]" -> sense_rank: 1 (displayed as 2, stored as 1)
11
+ * - "王 [radical]" -> sense_rank: -1
12
+ *
13
+ * Note: [0] and negative numbers in brackets are invalid and default to sense_rank 0.
14
+ */
15
+ export interface DecodedSearchText {
16
+ text: string;
17
+ senseRank: number;
18
+ }
19
+ /**
20
+ * Encodes a text and sense_rank into a search string.
21
+ *
22
+ * @param text The Chinese text
23
+ * @param senseRank The sense rank (-1, 0, 1, 2, ...)
24
+ * @returns Encoded search string
25
+ */
26
+ export declare function encodeSenseRank(text: string, senseRank: number): string;
27
+ /**
28
+ * Decodes a search string into text and sense_rank.
29
+ *
30
+ * @param searchText The search string (possibly encoded)
31
+ * @returns Decoded text and sense_rank
32
+ */
33
+ export declare function decodeSenseRank(searchText: string): DecodedSearchText;
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ /**
3
+ * Utilities for encoding and decoding sense_rank into search text.
4
+ *
5
+ * Encoding format (1-based for user display):
6
+ * - sense_rank >= 0: "text [N+1]" where N is the sense_rank (displayed as 1-based)
7
+ * - sense_rank = -1: "text [radical]"
8
+ *
9
+ * Examples:
10
+ * - "王 [1]" -> sense_rank: 0 (displayed as 1, stored as 0)
11
+ * - "王 [2]" -> sense_rank: 1 (displayed as 2, stored as 1)
12
+ * - "王 [radical]" -> sense_rank: -1
13
+ *
14
+ * Note: [0] and negative numbers in brackets are invalid and default to sense_rank 0.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.encodeSenseRank = encodeSenseRank;
18
+ exports.decodeSenseRank = decodeSenseRank;
19
+ /**
20
+ * Encodes a text and sense_rank into a search string.
21
+ *
22
+ * @param text The Chinese text
23
+ * @param senseRank The sense rank (-1, 0, 1, 2, ...)
24
+ * @returns Encoded search string
25
+ */
26
+ function encodeSenseRank(text, senseRank) {
27
+ if (senseRank === -1) {
28
+ return `${text} [radical]`;
29
+ }
30
+ else if (senseRank >= 0) {
31
+ // Convert to 1-based indexing for user display
32
+ return `${text} [${senseRank + 1}]`;
33
+ }
34
+ else {
35
+ // For any other negative values (shouldn't happen), treat as radical
36
+ return `${text} [radical]`;
37
+ }
38
+ }
39
+ /**
40
+ * Decodes a search string into text and sense_rank.
41
+ *
42
+ * @param searchText The search string (possibly encoded)
43
+ * @returns Decoded text and sense_rank
44
+ */
45
+ function decodeSenseRank(searchText) {
46
+ // Trim the input first to handle leading/trailing whitespace
47
+ const trimmed = searchText.trim();
48
+ // Pattern to match "[N]" or "[radical]" at the end of the string
49
+ // Whitespace before the bracket is optional
50
+ // Note: If multiple bracket groups exist (e.g., "要[1][2]"), the regex will match
51
+ // the last valid one due to backtracking, treating "要[1]" as text with senseRank=1
52
+ const pattern = /^(.+?)\s*\[(\d+|radical)\]$/;
53
+ const match = trimmed.match(pattern);
54
+ if (!match) {
55
+ // No encoding found, treat as sense_rank = 0
56
+ return {
57
+ text: searchText.trim(),
58
+ senseRank: 0
59
+ };
60
+ }
61
+ const text = match[1].trim();
62
+ const senseRankStr = match[2];
63
+ if (senseRankStr === 'radical') {
64
+ return {
65
+ text,
66
+ senseRank: -1
67
+ };
68
+ }
69
+ else {
70
+ // Convert from 1-based display indexing back to 0-based storage
71
+ const displayIndex = parseInt(senseRankStr, 10);
72
+ // [0] and negative numbers are invalid, default to sense_rank 0
73
+ if (displayIndex <= 0) {
74
+ return {
75
+ text,
76
+ senseRank: 0
77
+ };
78
+ }
79
+ return {
80
+ text,
81
+ senseRank: displayIndex - 1
82
+ };
83
+ }
84
+ }
@@ -3,3 +3,4 @@ export * from './ConditionMatcher';
3
3
  export * from './Database';
4
4
  export * from './Encryption';
5
5
  export * from './Logging';
6
+ export * from './SenseRankEncoder';
@@ -20,3 +20,4 @@ __exportStar(require("./ConditionMatcher"), exports);
20
20
  __exportStar(require("./Database"), exports);
21
21
  __exportStar(require("./Encryption"), exports);
22
22
  __exportStar(require("./Logging"), exports);
23
+ __exportStar(require("./SenseRankEncoder"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shaxpir/duiduidui-models",
3
- "version": "1.9.19",
3
+ "version": "1.9.21",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/shaxpir/duiduidui-models"
@@ -19,7 +19,6 @@
19
19
  "@shaxpir/duiduidui-models": "^1.4.14",
20
20
  "@shaxpir/sharedb": "^6.0.6",
21
21
  "@shaxpir/shaxpir-common": "^1.4.1",
22
- "glicko2-lite": "^4.0.0",
23
22
  "ot-json1": "1.0.1",
24
23
  "ot-text-unicode": "4.0.0",
25
24
  "reconnecting-websocket": "4.4.0"