@shaxpir/duiduidui-models 1.7.5 → 1.8.0

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.
@@ -1,12 +1,11 @@
1
1
  import { Doc } from '@shaxpir/sharedb/lib/client';
2
2
  import { CompactDate } from "@shaxpir/shaxpir-common";
3
3
  import { ShareSync } from '../repo';
4
- import { Bounds } from './BayesianScore';
5
4
  import { Content, ContentBody, ContentId, ContentMeta } from "./Content";
6
5
  export interface UncertainValue {
7
- value: number;
8
- uncertainty: number;
9
- bounds: Bounds;
6
+ rating: number;
7
+ rating_deviation: number;
8
+ volatility: number;
10
9
  }
11
10
  export interface StreakData {
12
11
  daily: {
@@ -43,16 +42,16 @@ export declare class Progress extends Content {
43
42
  get payload(): ProgressPayload;
44
43
  getSkillLevel(): UncertainValue;
45
44
  getSkillLevelValue(): number;
46
- getSkillLevelUncertainty(): number;
45
+ getSkillLevelRatingDeviation(): number;
46
+ getSkillLevelVolatility(): number;
47
47
  getSkillLevelLowerBound(): number;
48
48
  getSkillLevelUpperBound(): number;
49
49
  needsCalibration(): boolean;
50
50
  getCognitiveLoad(): number;
51
51
  setSkillLevel(uncertainValue: UncertainValue): void;
52
52
  setSkillLevelValue(value: number): void;
53
- setSkillLevelUncertainty(uncertainty: number): void;
54
- setSkillLevelLowerBound(lowerBound: number): void;
55
- setSkillLevelUpperBound(upperBound: number): void;
53
+ setSkillLevelRatingDeviation(ratingDeviation: number): void;
54
+ setSkillLevelVolatility(volatility: number): void;
56
55
  setCognitiveLoad(value: number): void;
57
56
  getStreaks(): StreakData | undefined;
58
57
  setStreaks(streaks: StreakData): void;
@@ -24,12 +24,9 @@ class Progress extends Content_1.Content {
24
24
  },
25
25
  payload: {
26
26
  skill_level: {
27
- value: 0, // Start at absolute beginner
28
- uncertainty: 1000, // Maximum uncertainty
29
- bounds: {
30
- lower: 0, // Can't be worse than absolute beginner
31
- upper: 1000 // Could potentially be advanced
32
- }
27
+ rating: 0, // Start at absolute beginner (knows ~0 characters)
28
+ rating_deviation: 50, // Initial uncertainty (±100 chars at 95% CI)
29
+ volatility: 0.06 // Moderate initial volatility
33
30
  },
34
31
  cognitive_load: 0,
35
32
  total_review_count: 0 // Start with zero lifetime reviews
@@ -49,24 +46,33 @@ class Progress extends Content_1.Content {
49
46
  }
50
47
  getSkillLevelValue() {
51
48
  this.checkDisposed("Progress.getSkillLevelValue");
52
- return this.payload.skill_level.value;
49
+ return this.payload.skill_level.rating;
53
50
  }
54
- getSkillLevelUncertainty() {
55
- this.checkDisposed("Progress.getSkillLevelUncertainty");
56
- return this.payload.skill_level.uncertainty;
51
+ getSkillLevelRatingDeviation() {
52
+ this.checkDisposed("Progress.getSkillLevelRatingDeviation");
53
+ return this.payload.skill_level.rating_deviation;
54
+ }
55
+ getSkillLevelVolatility() {
56
+ this.checkDisposed("Progress.getSkillLevelVolatility");
57
+ return this.payload.skill_level.volatility;
57
58
  }
58
59
  getSkillLevelLowerBound() {
59
60
  this.checkDisposed("Progress.getSkillLevelLowerBound");
60
- return this.payload.skill_level.bounds.lower;
61
+ const { SkillLevelModel } = require('./SkillLevel');
62
+ const bounds = SkillLevelModel.calculateBounds(this.payload.skill_level.rating, this.payload.skill_level.rating_deviation);
63
+ return bounds.lower;
61
64
  }
62
65
  getSkillLevelUpperBound() {
63
66
  this.checkDisposed("Progress.getSkillLevelUpperBound");
64
- return this.payload.skill_level.bounds.upper;
67
+ const { SkillLevelModel } = require('./SkillLevel');
68
+ const bounds = SkillLevelModel.calculateBounds(this.payload.skill_level.rating, this.payload.skill_level.rating_deviation);
69
+ return bounds.upper;
65
70
  }
66
71
  needsCalibration() {
67
72
  this.checkDisposed("Progress.needsCalibration");
68
- // High uncertainty means we need more data to calibrate user level
69
- return this.payload.skill_level.uncertainty > 50;
73
+ // High rating deviation means we need more data to calibrate user level
74
+ // rating_deviation > 30 means confidence bounds are still quite wide (±60 chars at 95% CI)
75
+ return this.payload.skill_level.rating_deviation > 30;
70
76
  }
71
77
  getCognitiveLoad() {
72
78
  this.checkDisposed("Progress.getCognitiveLoad");
@@ -76,42 +82,33 @@ class Progress extends Content_1.Content {
76
82
  this.checkDisposed("Progress.setSkillLevel");
77
83
  if (!shaxpir_common_1.Struct.equals(this.payload.skill_level, uncertainValue)) {
78
84
  const batch = new Operation_1.BatchOperation(this);
79
- batch.setPathValue(['payload', 'skill_level', 'value'], uncertainValue.value);
80
- batch.setPathValue(['payload', 'skill_level', 'uncertainty'], uncertainValue.uncertainty);
81
- batch.setPathValue(['payload', 'skill_level', 'bounds', 'lower'], uncertainValue.bounds.lower);
82
- batch.setPathValue(['payload', 'skill_level', 'bounds', 'upper'], uncertainValue.bounds.upper);
85
+ batch.setPathValue(['payload', 'skill_level', 'rating'], uncertainValue.rating);
86
+ batch.setPathValue(['payload', 'skill_level', 'rating_deviation'], uncertainValue.rating_deviation);
87
+ batch.setPathValue(['payload', 'skill_level', 'volatility'], uncertainValue.volatility);
83
88
  batch.commit();
84
89
  }
85
90
  }
86
91
  setSkillLevelValue(value) {
87
92
  this.checkDisposed("Progress.setSkillLevelValue");
88
- if (this.payload.skill_level.value !== value) {
89
- const batch = new Operation_1.BatchOperation(this);
90
- batch.setPathValue(['payload', 'skill_level', 'value'], value);
91
- batch.commit();
92
- }
93
- }
94
- setSkillLevelUncertainty(uncertainty) {
95
- this.checkDisposed("Progress.setSkillLevelUncertainty");
96
- if (this.payload.skill_level.uncertainty !== uncertainty) {
93
+ if (this.payload.skill_level.rating !== value) {
97
94
  const batch = new Operation_1.BatchOperation(this);
98
- batch.setPathValue(['payload', 'skill_level', 'uncertainty'], uncertainty);
95
+ batch.setPathValue(['payload', 'skill_level', 'rating'], value);
99
96
  batch.commit();
100
97
  }
101
98
  }
102
- setSkillLevelLowerBound(lowerBound) {
103
- this.checkDisposed("Progress.setSkillLevelLowerBound");
104
- if (this.payload.skill_level.bounds.lower !== lowerBound) {
99
+ setSkillLevelRatingDeviation(ratingDeviation) {
100
+ this.checkDisposed("Progress.setSkillLevelRatingDeviation");
101
+ if (this.payload.skill_level.rating_deviation !== ratingDeviation) {
105
102
  const batch = new Operation_1.BatchOperation(this);
106
- batch.setPathValue(['payload', 'skill_level', 'bounds', 'lower'], lowerBound);
103
+ batch.setPathValue(['payload', 'skill_level', 'rating_deviation'], ratingDeviation);
107
104
  batch.commit();
108
105
  }
109
106
  }
110
- setSkillLevelUpperBound(upperBound) {
111
- this.checkDisposed("Progress.setSkillLevelUpperBound");
112
- if (this.payload.skill_level.bounds.upper !== upperBound) {
107
+ setSkillLevelVolatility(volatility) {
108
+ this.checkDisposed("Progress.setSkillLevelVolatility");
109
+ if (this.payload.skill_level.volatility !== volatility) {
113
110
  const batch = new Operation_1.BatchOperation(this);
114
- batch.setPathValue(['payload', 'skill_level', 'bounds', 'upper'], upperBound);
111
+ batch.setPathValue(['payload', 'skill_level', 'volatility'], volatility);
115
112
  batch.commit();
116
113
  }
117
114
  }
@@ -0,0 +1,72 @@
1
+ import { ReviewResult } from './Review';
2
+ import { Bounds } from './BayesianScore';
3
+ /**
4
+ * Represents a skill level rating with uncertainty bounds.
5
+ * Uses the Glicko-2 rating system for skill assessment.
6
+ */
7
+ export interface SkillLevel {
8
+ rating: number;
9
+ rating_deviation: number;
10
+ volatility: number;
11
+ }
12
+ /**
13
+ * Model for updating and managing skill levels using the Glicko-2 rating system.
14
+ *
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
20
+ *
21
+ * The Glicko-2 algorithm is used without the typical scaling transformations,
22
+ * working directly on our 0-10000 absolute scale.
23
+ */
24
+ export declare class SkillLevelModel {
25
+ /**
26
+ * Update skill level using the Glicko-2 algorithm after a review.
27
+ *
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)
33
+ * @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
36
+ */
37
+ static updateWithGlicko2(rating: number, ratingDeviation: number, volatility: number, cardDifficulty: number, cardRatingDeviation: number, outcome: ReviewResult, tau?: number): SkillLevel;
38
+ /**
39
+ * Map a review outcome to a numeric score for Glicko-2.
40
+ *
41
+ * @param outcome Review result
42
+ * @returns Numeric score from 0.0 (complete failure) to 1.0 (perfect success)
43
+ */
44
+ static outcomeToScore(outcome: ReviewResult): number;
45
+ /**
46
+ * Calculate confidence bounds from Glicko-2 parameters.
47
+ * Returns a proper statistical confidence interval.
48
+ *
49
+ * @param rating Current skill level rating
50
+ * @param ratingDeviation Rating deviation (standard deviation of rating)
51
+ * @param confidence Confidence level (0.95 for 95%, 0.99 for 99%)
52
+ * @returns Bounds object with lower and upper confidence limits
53
+ */
54
+ static calculateBounds(rating: number, ratingDeviation: number, confidence?: number): Bounds;
55
+ /**
56
+ * Create a default skill level for a new user.
57
+ * Starts at rating 0 (knows no characters) with high uncertainty.
58
+ *
59
+ * @param initialRatingDeviation Initial rating deviation (default 50, meaning ±100 char uncertainty at 95% CI)
60
+ * @param initialVolatility Initial volatility (default 0.06, moderate)
61
+ * @returns New SkillLevel object
62
+ */
63
+ static createDefault(initialRatingDeviation?: number, initialVolatility?: number): SkillLevel;
64
+ /**
65
+ * Check if the skill level has high confidence (low uncertainty).
66
+ *
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)
70
+ */
71
+ static isHighConfidence(ratingDeviation: number, threshold?: number): boolean;
72
+ }
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.SkillLevelModel = void 0;
7
+ const glicko2_lite_1 = __importDefault(require("glicko2-lite"));
8
+ /**
9
+ * Model for updating and managing skill levels using the Glicko-2 rating system.
10
+ *
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
16
+ *
17
+ * The Glicko-2 algorithm is used without the typical scaling transformations,
18
+ * working directly on our 0-10000 absolute scale.
19
+ */
20
+ class SkillLevelModel {
21
+ /**
22
+ * Update skill level using the Glicko-2 algorithm after a review.
23
+ *
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)
29
+ * @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
32
+ */
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 });
41
+ return {
42
+ rating: result.rating,
43
+ rating_deviation: result.rd,
44
+ volatility: result.vol
45
+ };
46
+ }
47
+ /**
48
+ * Map a review outcome to a numeric score for Glicko-2.
49
+ *
50
+ * @param outcome Review result
51
+ * @returns Numeric score from 0.0 (complete failure) to 1.0 (perfect success)
52
+ */
53
+ static outcomeToScore(outcome) {
54
+ switch (outcome) {
55
+ case 'EASY': return 1.0;
56
+ case 'GOOD': return 0.67;
57
+ case 'HARD': return 0.33;
58
+ case 'FAIL': return 0.0;
59
+ }
60
+ }
61
+ /**
62
+ * Calculate confidence bounds from Glicko-2 parameters.
63
+ * Returns a proper statistical confidence interval.
64
+ *
65
+ * @param rating Current skill level rating
66
+ * @param ratingDeviation Rating deviation (standard deviation of rating)
67
+ * @param confidence Confidence level (0.95 for 95%, 0.99 for 99%)
68
+ * @returns Bounds object with lower and upper confidence limits
69
+ */
70
+ static calculateBounds(rating, ratingDeviation, confidence = 0.95) {
71
+ // Z-score for desired confidence level
72
+ // 95% CI: ±1.96 standard deviations
73
+ // 99% CI: ±2.58 standard deviations
74
+ const z = confidence === 0.95 ? 1.96 : confidence === 0.99 ? 2.58 : 1.96;
75
+ return {
76
+ lower: Math.max(0, rating - z * ratingDeviation),
77
+ upper: rating + z * ratingDeviation
78
+ };
79
+ }
80
+ /**
81
+ * Create a default skill level for a new user.
82
+ * Starts at rating 0 (knows no characters) with high uncertainty.
83
+ *
84
+ * @param initialRatingDeviation Initial rating deviation (default 50, meaning ±100 char uncertainty at 95% CI)
85
+ * @param initialVolatility Initial volatility (default 0.06, moderate)
86
+ * @returns New SkillLevel object
87
+ */
88
+ static createDefault(initialRatingDeviation = 50, initialVolatility = 0.06) {
89
+ return {
90
+ rating: 0,
91
+ rating_deviation: initialRatingDeviation,
92
+ volatility: initialVolatility
93
+ };
94
+ }
95
+ /**
96
+ * Check if the skill level has high confidence (low uncertainty).
97
+ *
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)
101
+ */
102
+ static isHighConfidence(ratingDeviation, threshold = 15) {
103
+ return ratingDeviation < threshold;
104
+ }
105
+ }
106
+ exports.SkillLevelModel = SkillLevelModel;
@@ -94,10 +94,10 @@ class Streaks {
94
94
  }
95
95
  }
96
96
  // Calculate monthly streak
97
- // Extract YYYY-MM from CompactDate (format: YYYY-MM-DD)
97
+ // Extract YYYYMM from CompactDate
98
98
  const monthlyActivity = new Set();
99
99
  for (const date of activityDates) {
100
- const month = date.substring(0, 7); // Extract YYYY-MM
100
+ const month = shaxpir_common_1.Time.getYearMonth(date);
101
101
  monthlyActivity.add(month);
102
102
  }
103
103
  const sortedMonths = Array.from(monthlyActivity).sort();
@@ -110,10 +110,7 @@ class Streaks {
110
110
  }
111
111
  else {
112
112
  // Check if consecutive month
113
- const [lastYear, lastMonthNum] = lastMonth.split('-').map(Number);
114
- const [currentYear, currentMonthNum] = month.split('-').map(Number);
115
- if ((currentYear === lastYear && currentMonthNum === lastMonthNum + 1) ||
116
- (currentYear === lastYear + 1 && lastMonthNum === 12 && currentMonthNum === 1)) {
113
+ if (shaxpir_common_1.Time.areConsecutiveMonths(lastMonth, month)) {
117
114
  monthlyStreak++;
118
115
  }
119
116
  else {
@@ -123,23 +120,13 @@ class Streaks {
123
120
  lastMonth = month;
124
121
  }
125
122
  // Check if current month streak is active
126
- const currentMonth = today.substring(0, 7); // Extract YYYY-MM
123
+ const currentMonth = shaxpir_common_1.Time.getYearMonth(today);
127
124
  const lastActiveMonth = sortedMonths[sortedMonths.length - 1];
128
125
  const lastMonthDate = shaxpir_common_1.Time.plus(today, -30, 'days');
129
- const previousMonth = shaxpir_common_1.Time.dateFrom(lastMonthDate).substring(0, 7);
126
+ const previousMonth = shaxpir_common_1.Time.getYearMonth(shaxpir_common_1.Time.dateFrom(lastMonthDate));
130
127
  if (lastActiveMonth === currentMonth || lastActiveMonth === previousMonth) {
131
128
  monthlyCurrent = monthlyStreak;
132
129
  }
133
- // Get week string for last activity (ISO week format)
134
- const getWeekString = (date) => {
135
- // Simple week calculation: YYYY-Www where ww is week of year
136
- const [year, month, day] = date.split('-').map(Number);
137
- const dateObj = new Date(year, month - 1, day);
138
- const startOfYear = new Date(year, 0, 1);
139
- const dayOfYear = Math.floor((dateObj.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000)) + 1;
140
- const weekNumber = Math.ceil(dayOfYear / 7);
141
- return `${year}-W${weekNumber.toString().padStart(2, '0')}`;
142
- };
143
130
  // We know lastDate has a value since activityDates.length > 0
144
131
  const finalLastDate = lastDate || activityDates[activityDates.length - 1];
145
132
  return {
@@ -155,8 +142,8 @@ class Streaks {
155
142
  },
156
143
  last_activity: {
157
144
  date: finalLastDate,
158
- week: getWeekString(finalLastDate),
159
- month: finalLastDate.substring(0, 7)
145
+ week: shaxpir_common_1.Time.getWeekString(finalLastDate),
146
+ month: shaxpir_common_1.Time.getYearMonth(finalLastDate)
160
147
  },
161
148
  total_days: activityDates.length
162
149
  };
@@ -20,6 +20,7 @@ export * from './Profile';
20
20
  export * from './Progress';
21
21
  export * from './Review';
22
22
  export * from './Session';
23
+ export * from './SkillLevel';
23
24
  export * from './Social';
24
25
  export * from './Streaks';
25
26
  export * from './Term';
@@ -37,6 +37,7 @@ __exportStar(require("./Profile"), exports);
37
37
  __exportStar(require("./Progress"), exports);
38
38
  __exportStar(require("./Review"), exports);
39
39
  __exportStar(require("./Session"), exports);
40
+ __exportStar(require("./SkillLevel"), exports);
40
41
  __exportStar(require("./Social"), exports);
41
42
  __exportStar(require("./Streaks"), exports);
42
43
  __exportStar(require("./Term"), exports);
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@shaxpir/duiduidui-models",
3
- "version": "1.7.5",
3
+ "version": "1.8.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/shaxpir/duiduidui-models"
7
7
  },
8
8
  "scripts": {
9
9
  "build": "tsc",
10
- "test": "mocha tests",
11
- "test:watch": "mocha -w tests"
10
+ "test": "npm run build && mocha -r ts-node/register tests/**/*.ts",
11
+ "test:watch": "npm run build && mocha -r ts-node/register -w tests/**/*.ts"
12
12
  },
13
13
  "main": "dist/index.js",
14
14
  "types": "dist/index.d.ts",
@@ -18,16 +18,20 @@
18
18
  "dependencies": {
19
19
  "@shaxpir/duiduidui-models": "^1.4.14",
20
20
  "@shaxpir/sharedb": "^6.0.6",
21
- "@shaxpir/shaxpir-common": "1.4.0",
21
+ "@shaxpir/shaxpir-common": "^1.4.1",
22
+ "glicko2-lite": "^4.0.0",
22
23
  "ot-json1": "1.0.1",
23
24
  "ot-text-unicode": "4.0.0",
24
25
  "reconnecting-websocket": "4.4.0"
25
26
  },
26
27
  "devDependencies": {
28
+ "@types/chai": "^5.2.3",
27
29
  "@types/lodash": "^4.17.20",
30
+ "@types/mocha": "^10.0.10",
28
31
  "@types/node": "^18.0.50",
29
32
  "chai": "^4.3.7",
30
33
  "mocha": "^10.2.0",
34
+ "ts-node": "^10.9.2",
31
35
  "tslint": "^5.12.1",
32
36
  "typescript": "latest"
33
37
  }