@vue-skuilder/db 0.2.10 → 0.2.12
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.
- package/dist/core/index.d.cts +3 -3
- package/dist/core/index.d.ts +3 -3
- package/dist/core/index.js +22 -17
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +22 -17
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.js +22 -17
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +22 -17
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.js +22 -17
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +22 -17
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +13 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.js +68 -44
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +67 -44
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/core/navigators/SrsDebugger.ts +8 -7
- package/src/core/navigators/generators/srs.ts +40 -28
- package/src/study/SessionController.ts +5 -1
- package/src/study/SessionDebugger.ts +16 -8
- package/src/study/SessionOverlay.ts +17 -23
- package/src/study/services/ResponseProcessor.ts +56 -22
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.2.
|
|
7
|
+
"version": "0.2.12",
|
|
8
8
|
"description": "Database layer for vue-skuilder",
|
|
9
9
|
"main": "dist/index.js",
|
|
10
10
|
"module": "dist/index.mjs",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@nilock2/pouchdb-authentication": "^1.0.2",
|
|
51
|
-
"@vue-skuilder/common": "0.2.
|
|
51
|
+
"@vue-skuilder/common": "0.2.12",
|
|
52
52
|
"cross-fetch": "^4.1.0",
|
|
53
53
|
"moment": "^2.29.4",
|
|
54
54
|
"pouchdb": "^9.0.0",
|
|
@@ -62,5 +62,5 @@
|
|
|
62
62
|
"vite": "^8.0.0",
|
|
63
63
|
"vitest": "^4.1.0"
|
|
64
64
|
},
|
|
65
|
-
"stableVersion": "0.2.
|
|
65
|
+
"stableVersion": "0.2.12"
|
|
66
66
|
}
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// ============================================================================
|
|
4
4
|
//
|
|
5
5
|
// A tiny module-level capture of the SRS generator's per-run backlog state,
|
|
6
|
-
// so the live session overlay can show
|
|
7
|
-
// (boosted) new cards
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// so the live session overlay can show how much runway remains before reviews
|
|
7
|
+
// out-compete the current (boosted) new/prescribed cards: the backlog
|
|
8
|
+
// multiplier is exponential and uncapped, so it always has headroom to climb
|
|
9
|
+
// further — the question is just how large the backlog needs to get before it
|
|
10
|
+
// crosses whatever the competing boosts currently sit at.
|
|
10
11
|
//
|
|
11
12
|
// Mirrors MixerDebugger/PipelineDebugger: the generator pushes a snapshot each
|
|
12
13
|
// run (keyed by course, latest wins); the overlay reads it via the controller's
|
|
@@ -23,10 +24,10 @@ export interface SrsBacklogDebug {
|
|
|
23
24
|
dueNow: number;
|
|
24
25
|
/** Healthy backlog threshold; multiplier is ×1.0 at or below this. */
|
|
25
26
|
healthyBacklog: number;
|
|
26
|
-
/** Global multiplier applied to every due review's urgency this run (1.0
|
|
27
|
+
/** Global multiplier applied to every due review's urgency this run (>= 1.0, unbounded). */
|
|
27
28
|
backlogMultiplier: number;
|
|
28
|
-
/**
|
|
29
|
-
|
|
29
|
+
/** Exponential growth-rate base the multiplier climbs by per multiple of healthyBacklog excess. */
|
|
30
|
+
backlogGrowthRate: number;
|
|
30
31
|
/** Highest review score produced this run (post-multiplier; can exceed 1.0); null if none due. */
|
|
31
32
|
topReviewScore: number | null;
|
|
32
33
|
/** Human-readable time until the next review comes due, or null if some are due now. */
|
|
@@ -42,19 +42,27 @@ import { logger } from '@db/util/logger';
|
|
|
42
42
|
const DEFAULT_HEALTHY_BACKLOG = 20;
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
45
|
+
* Growth-rate base for backlog pressure as a *multiplier* on review urgency.
|
|
46
46
|
*
|
|
47
|
-
* Backlog pressure is multiplicative (×1.0 at/below healthy
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
47
|
+
* Backlog pressure is multiplicative (×1.0 at/below healthy) and exponential
|
|
48
|
+
* in the backlog's excess over healthy, expressed in multiples of
|
|
49
|
+
* `healthyBacklog` (see computeBacklogMultiplier) — deliberately uncapped. It
|
|
50
|
+
* replaces an older additive +0..+0.5 term that was a [0,1]-era modifier —
|
|
51
|
+
* once review scores stopped being clamped to 1.0 and new cards could be
|
|
52
|
+
* boosted well past it (e.g. an intro ×5 → 7+), a flat +0.5 was both too small
|
|
53
|
+
* to compete and mostly eaten by the old 1.0 clamp. A linear-then-capped
|
|
54
|
+
* multiplier came next, but that just moved the problem: other generators
|
|
55
|
+
* (e.g. Prescribed Intro Backpressure) score on a much larger open scale with
|
|
56
|
+
* their own, independently-tuned caps, so a hard review-side ceiling meant
|
|
57
|
+
* reviews could never win out no matter how backlogged they got. Exponential
|
|
58
|
+
* growth has no such ceiling — a sufficiently neglected backlog keeps
|
|
59
|
+
* climbing until it outcompetes anything, which is the intended long-term
|
|
60
|
+
* fallback: other generators can dominate short-term, but SRS is the
|
|
61
|
+
* framework-invariant backstop and should always win eventually. Tunable —
|
|
62
|
+
* verify review vs new ordering in the dbg overlay's "review backpressure"
|
|
63
|
+
* panel.
|
|
56
64
|
*/
|
|
57
|
-
const
|
|
65
|
+
const BACKLOG_GROWTH_RATE = 2;
|
|
58
66
|
|
|
59
67
|
/**
|
|
60
68
|
* Configuration for the SRS strategy.
|
|
@@ -249,7 +257,7 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
249
257
|
dueNow: dueReviews.length,
|
|
250
258
|
healthyBacklog: this.healthyBacklog,
|
|
251
259
|
backlogMultiplier,
|
|
252
|
-
|
|
260
|
+
backlogGrowthRate: BACKLOG_GROWTH_RATE,
|
|
253
261
|
topReviewScore: sorted.length > 0 ? sorted[0].score : null,
|
|
254
262
|
nextDueIn,
|
|
255
263
|
timestamp: Date.now(),
|
|
@@ -268,28 +276,32 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
268
276
|
/**
|
|
269
277
|
* Compute the multiplicative backlog pressure based on number of due reviews.
|
|
270
278
|
*
|
|
271
|
-
* ×1.0 at or below the healthy threshold (no boost),
|
|
272
|
-
*
|
|
279
|
+
* ×1.0 at or below the healthy threshold (no boost); above it, grows as
|
|
280
|
+
* BACKLOG_GROWTH_RATE raised to the excess expressed in multiples of the
|
|
281
|
+
* healthy threshold. Uncapped — self-regulating instead: reviews winning
|
|
282
|
+
* slots depletes dueCount, which drops the ratio and relaxes the multiplier
|
|
283
|
+
* next run.
|
|
273
284
|
*
|
|
274
|
-
* Examples (with default healthyBacklog=20,
|
|
275
|
-
* - 10 due reviews → ×1.00
|
|
276
|
-
* - 20 due reviews → ×1.00
|
|
277
|
-
* - 40 due reviews → ×1.50
|
|
278
|
-
* - 60 due reviews → ×2.
|
|
285
|
+
* Examples (with default healthyBacklog=20, BACKLOG_GROWTH_RATE=1.5):
|
|
286
|
+
* - 10 due reviews → ×1.00 (healthy)
|
|
287
|
+
* - 20 due reviews → ×1.00 (at threshold, ratio 0)
|
|
288
|
+
* - 40 due reviews → ×1.50 (ratio 1, 2x threshold)
|
|
289
|
+
* - 60 due reviews → ×2.25 (ratio 2, 3x threshold — old hard cap was ×2.00 here)
|
|
290
|
+
* - 100 due reviews → ×5.06 (ratio 4, 5x threshold)
|
|
291
|
+
* - 160 due reviews → ×17.09 (ratio 7, 8x threshold)
|
|
279
292
|
*
|
|
280
293
|
* @param dueCount - Number of reviews currently due
|
|
281
|
-
* @returns Multiplier applied to review urgency (1.0
|
|
294
|
+
* @returns Multiplier applied to review urgency (>= 1.0, unbounded)
|
|
282
295
|
*/
|
|
283
296
|
private computeBacklogMultiplier(dueCount: number): number {
|
|
284
297
|
if (dueCount <= this.healthyBacklog) {
|
|
285
298
|
return 1.0;
|
|
286
299
|
}
|
|
287
300
|
|
|
288
|
-
// Linear in excess: ×1 at healthy, reaching MAX at 3× healthy (excess = 2×healthy).
|
|
289
301
|
const excess = dueCount - this.healthyBacklog;
|
|
290
|
-
const
|
|
302
|
+
const ratio = excess / this.healthyBacklog;
|
|
291
303
|
|
|
292
|
-
return Math.
|
|
304
|
+
return Math.pow(BACKLOG_GROWTH_RATE, ratio);
|
|
293
305
|
}
|
|
294
306
|
|
|
295
307
|
/**
|
|
@@ -306,17 +318,17 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
306
318
|
* - 180 days → ~0.30
|
|
307
319
|
*
|
|
308
320
|
* 3. Backlog pressure = global *multiplier* when review backlog exceeds the
|
|
309
|
-
* healthy threshold (×1.0 healthy →
|
|
321
|
+
* healthy threshold (×1.0 healthy → exponential growth, uncapped, above it).
|
|
310
322
|
*
|
|
311
323
|
* Combined: (base 0.5 + urgency factors * 0.45) × backlog multiplier.
|
|
312
324
|
* Per-card range before pressure: ~0.57–0.95. NOT clamped to 1.0 — under a
|
|
313
325
|
* heavy backlog reviews scale onto the open scale to compete with (and exceed)
|
|
314
|
-
* new cards;
|
|
315
|
-
*
|
|
326
|
+
* new cards; there's no ceiling at all now, so a bad enough backlog always
|
|
327
|
+
* wins eventually — self-regulating because winning slots depletes dueCount.
|
|
316
328
|
*
|
|
317
329
|
* @param review - The scheduled card to score
|
|
318
330
|
* @param now - Current time
|
|
319
|
-
* @param backlogMultiplier - Pre-computed backlog multiplier (1.0
|
|
331
|
+
* @param backlogMultiplier - Pre-computed backlog multiplier (>= 1.0, unbounded)
|
|
320
332
|
*/
|
|
321
333
|
private computeUrgencyScore(
|
|
322
334
|
review: ScheduledCard,
|
|
@@ -366,4 +378,4 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
|
|
|
366
378
|
|
|
367
379
|
return { score, reason };
|
|
368
380
|
}
|
|
369
|
-
}
|
|
381
|
+
}
|
|
@@ -20,7 +20,7 @@ import { ReplanHints } from '@db/core/navigators/generators/types';
|
|
|
20
20
|
import { mergeHints } from '@db/core/navigators/Pipeline';
|
|
21
21
|
import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
|
|
22
22
|
import { captureMixerRun } from './MixerDebugger';
|
|
23
|
-
import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
|
|
23
|
+
import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking, clearStaleSessionDebugState } from './SessionDebugger';
|
|
24
24
|
import {
|
|
25
25
|
registerActiveController,
|
|
26
26
|
type SessionDebugSnapshot,
|
|
@@ -475,6 +475,10 @@ export class SessionController<TView = unknown> extends Loggable {
|
|
|
475
475
|
);
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
// Clear the previous session's debug state before this session's
|
|
479
|
+
// bootstrap pipeline run fires, so that run's own report isn't wiped out.
|
|
480
|
+
clearStaleSessionDebugState();
|
|
481
|
+
|
|
478
482
|
const wellIndicated = await this.getWeightedContent();
|
|
479
483
|
this._wellIndicatedRemaining = wellIndicated;
|
|
480
484
|
if (wellIndicated >= 0 && wellIndicated < SessionController.MIN_WELL_INDICATED) {
|
|
@@ -68,6 +68,22 @@ let activeSession: SessionRunReport | null = null;
|
|
|
68
68
|
const sessionHistory: SessionRunReport[] = [];
|
|
69
69
|
const MAX_HISTORY = 5;
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Release pipeline run-history / SRS backlog debug memory from the previous
|
|
73
|
+
* session before this session begins piling new runs on top of it. Must be
|
|
74
|
+
* called before the first pipeline run of the new session (including the
|
|
75
|
+
* bootstrap run in prepareSession) — clearing on session START rather than
|
|
76
|
+
* END keeps post-session inspection, the dominant debugging workflow, fully
|
|
77
|
+
* functional: a finished session's run history sits intact until the user
|
|
78
|
+
* actually begins another one. It's a separate call from startSessionTracking
|
|
79
|
+
* because that function can't run until supplyQ/failedQ are populated by the
|
|
80
|
+
* bootstrap pipeline run, which would otherwise be cleared out from under it.
|
|
81
|
+
*/
|
|
82
|
+
export function clearStaleSessionDebugState(): void {
|
|
83
|
+
clearPipelineRunHistory();
|
|
84
|
+
clearSrsBacklogDebug();
|
|
85
|
+
}
|
|
86
|
+
|
|
71
87
|
/**
|
|
72
88
|
* Start tracking a new session.
|
|
73
89
|
*/
|
|
@@ -75,14 +91,6 @@ export function startSessionTracking(
|
|
|
75
91
|
supplyQLength: number,
|
|
76
92
|
failedQLength: number
|
|
77
93
|
): void {
|
|
78
|
-
// Release pipeline run-history memory before this session begins piling
|
|
79
|
-
// new runs on top of the previous session's retained reports. Clearing on
|
|
80
|
-
// session START (rather than END) keeps post-session inspection — the
|
|
81
|
-
// dominant debugging workflow — fully functional: a finished session's
|
|
82
|
-
// run history sits intact until the user actually begins another one.
|
|
83
|
-
clearPipelineRunHistory();
|
|
84
|
-
clearSrsBacklogDebug();
|
|
85
|
-
|
|
86
94
|
const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
87
95
|
|
|
88
96
|
activeSession = {
|
|
@@ -365,25 +365,24 @@ function hintsHtml(h: ReplanHints | null): string {
|
|
|
365
365
|
|
|
366
366
|
/**
|
|
367
367
|
* SRS backlog panel — answers "is review starvation permanent?". Backlog
|
|
368
|
-
* pressure is a multiplier (×1.0 healthy →
|
|
369
|
-
* no longer clamped to 1.0, so under a heavy backlog they
|
|
370
|
-
* with (and exceed) new cards.
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
368
|
+
* pressure is a multiplier (×1.0 healthy → exponential, uncapped) on review
|
|
369
|
+
* urgency; reviews are no longer clamped to 1.0, so under a heavy backlog they
|
|
370
|
+
* scale up to compete with (and exceed) new cards. There's no ceiling, so
|
|
371
|
+
* reviews always win eventually — the panel shows how far the current
|
|
372
|
+
* multiplier is from that crossover, not whether it's "maxed": compare
|
|
373
|
+
* `top review` here against the supplyQ head score to see how much further
|
|
374
|
+
* the backlog needs to grow (or the boosts need to relax) before reviews win
|
|
375
|
+
* slots.
|
|
376
376
|
*/
|
|
377
377
|
function backlogHtml(backlog: SrsBacklogDebug[]): string {
|
|
378
378
|
if (!backlog.length) return '';
|
|
379
379
|
const rows = backlog
|
|
380
380
|
.map((b) => {
|
|
381
|
-
const
|
|
382
|
-
const multColor = b.backlogMultiplier <= 1 ? '#86efac' :
|
|
383
|
-
const headroom =
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
? 'climbing as backlog grows'
|
|
381
|
+
const hot = b.backlogMultiplier >= 3;
|
|
382
|
+
const multColor = b.backlogMultiplier <= 1 ? '#86efac' : hot ? '#fca5a5' : '#fcd34d';
|
|
383
|
+
const headroom =
|
|
384
|
+
b.backlogMultiplier > 1
|
|
385
|
+
? `climbing (×${b.backlogGrowthRate.toFixed(2)} per ${b.healthyBacklog}-review excess, unbounded)`
|
|
387
386
|
: 'healthy — no pressure';
|
|
388
387
|
const top = b.topReviewScore !== null ? b.topReviewScore.toFixed(2) : '—';
|
|
389
388
|
const next = b.nextDueIn ? ` <span style="opacity:.6">· next due ${esc(b.nextDueIn)}</span>` : '';
|
|
@@ -392,7 +391,7 @@ function backlogHtml(backlog: SrsBacklogDebug[]): string {
|
|
|
392
391
|
`<span style="opacity:.7">${esc(b.courseId.slice(0, 8))}</span> ` +
|
|
393
392
|
`due ${b.dueNow}/${b.scheduledTotal} <span style="opacity:.6">(healthy ${b.healthyBacklog})</span>${next}` +
|
|
394
393
|
`<div style="margin-left:6px">` +
|
|
395
|
-
`pressure <span style="color:${multColor}">×${b.backlogMultiplier.toFixed(2)}
|
|
394
|
+
`pressure <span style="color:${multColor}">×${b.backlogMultiplier.toFixed(2)}</span> ` +
|
|
396
395
|
`<span style="opacity:.6">${headroom} · top review ${top}</span></div>` +
|
|
397
396
|
`</div>`
|
|
398
397
|
);
|
|
@@ -541,18 +540,13 @@ function snapshotToText(s: SessionDebugSnapshot | null): string {
|
|
|
541
540
|
lines.push('');
|
|
542
541
|
lines.push('review backpressure:');
|
|
543
542
|
for (const b of s.reviewBacklog) {
|
|
544
|
-
const
|
|
545
|
-
const headroom = maxed
|
|
546
|
-
? 'maxed (boosts decide order)'
|
|
547
|
-
: b.backlogMultiplier > 1
|
|
548
|
-
? 'climbing'
|
|
549
|
-
: 'healthy';
|
|
543
|
+
const headroom = b.backlogMultiplier > 1 ? 'climbing' : 'healthy';
|
|
550
544
|
const top = b.topReviewScore !== null ? b.topReviewScore.toFixed(2) : '—';
|
|
551
545
|
const next = b.nextDueIn ? `, next due ${b.nextDueIn}` : '';
|
|
552
546
|
lines.push(
|
|
553
547
|
` ${b.courseId.slice(0, 8)}: due ${b.dueNow}/${b.scheduledTotal} ` +
|
|
554
|
-
`(healthy ${b.healthyBacklog})${next}; pressure ×${b.backlogMultiplier.toFixed(2)}
|
|
555
|
-
`${
|
|
548
|
+
`(healthy ${b.healthyBacklog})${next}; pressure ×${b.backlogMultiplier.toFixed(2)} ` +
|
|
549
|
+
`${headroom}; top review ${top}`
|
|
556
550
|
);
|
|
557
551
|
}
|
|
558
552
|
}
|
|
@@ -189,7 +189,9 @@ export class ResponseProcessor {
|
|
|
189
189
|
const nullTags = tagKeys.filter((k) => taggedPerformance[k] === null);
|
|
190
190
|
const scoredTags = tagKeys.filter((k) => taggedPerformance[k] !== null);
|
|
191
191
|
logger.info(
|
|
192
|
-
`[
|
|
192
|
+
`[FirstContactElo] correct first-attempt per-tag ELO update for ${cardId} ` +
|
|
193
|
+
`(historyLen=${history.records.length}, priorAttemps=${cardRecord.priorAttemps}): ` +
|
|
194
|
+
`scored=[${scoredTags.join(', ')}] count-only=[${nullTags.join(', ')}]`
|
|
193
195
|
);
|
|
194
196
|
|
|
195
197
|
void this.eloService.updateUserAndCardEloPerTag(
|
|
@@ -225,7 +227,8 @@ export class ResponseProcessor {
|
|
|
225
227
|
);
|
|
226
228
|
}
|
|
227
229
|
logger.info(
|
|
228
|
-
|
|
230
|
+
`[FirstContactElo] correct first-attempt ELO update (score=${userScore.toFixed(3)}) ` +
|
|
231
|
+
`for ${cardId} (historyLen=${history.records.length}, priorAttemps=${cardRecord.priorAttemps})`
|
|
229
232
|
);
|
|
230
233
|
}
|
|
231
234
|
|
|
@@ -270,8 +273,17 @@ export class ResponseProcessor {
|
|
|
270
273
|
// Parse performance (may be numeric or structured)
|
|
271
274
|
const { taggedPerformance } = this.parsePerformance(cardRecord.performance);
|
|
272
275
|
|
|
273
|
-
//
|
|
274
|
-
|
|
276
|
+
// Tracks whether this response already produced an ELO update, so the
|
|
277
|
+
// dismiss-failed branch below doesn't double-penalize the same record (the
|
|
278
|
+
// two coincide when maxAttemptsPerView === 1: first contact and final
|
|
279
|
+
// failure land on one response).
|
|
280
|
+
let eloUpdated = false;
|
|
281
|
+
|
|
282
|
+
// Update ELO on the first attempt of a presentation — symmetric with the
|
|
283
|
+
// correct path. Previously this was gated on `history.records.length !== 1`,
|
|
284
|
+
// so first-EVER failures were skipped while first-ever successes updated,
|
|
285
|
+
// biasing cold-start ELO upward. See WORKING-first-contact-elo-asymmetry.md.
|
|
286
|
+
if (cardRecord.priorAttemps === 0) {
|
|
275
287
|
if (taggedPerformance) {
|
|
276
288
|
// Per-tag ELO update for incorrect response
|
|
277
289
|
void this.eloService.updateUserAndCardEloPerTag(
|
|
@@ -282,7 +294,9 @@ export class ResponseProcessor {
|
|
|
282
294
|
currentCard
|
|
283
295
|
);
|
|
284
296
|
logger.info(
|
|
285
|
-
`[
|
|
297
|
+
`[FirstContactElo] incorrect first-attempt per-tag ELO update for ${cardId} ` +
|
|
298
|
+
`(historyLen=${history.records.length}, priorAttemps=${cardRecord.priorAttemps}, ` +
|
|
299
|
+
`tags=${Object.keys(taggedPerformance).length - 1})`
|
|
286
300
|
);
|
|
287
301
|
} else {
|
|
288
302
|
// Standard single-score ELO update
|
|
@@ -293,32 +307,52 @@ export class ResponseProcessor {
|
|
|
293
307
|
courseRegistrationDoc,
|
|
294
308
|
currentCard
|
|
295
309
|
);
|
|
296
|
-
logger.info(
|
|
310
|
+
logger.info(
|
|
311
|
+
`[FirstContactElo] incorrect first-attempt ELO update (score=0) for ${cardId} ` +
|
|
312
|
+
`(historyLen=${history.records.length}, priorAttemps=${cardRecord.priorAttemps})`
|
|
313
|
+
);
|
|
297
314
|
}
|
|
315
|
+
eloUpdated = true;
|
|
298
316
|
} else {
|
|
299
|
-
logger.info(
|
|
317
|
+
logger.info(
|
|
318
|
+
`[FirstContactElo] incorrect retry — no ELO update for ${cardId} ` +
|
|
319
|
+
`(historyLen=${history.records.length}, priorAttemps=${cardRecord.priorAttemps})`
|
|
320
|
+
);
|
|
300
321
|
}
|
|
301
322
|
|
|
302
323
|
// Determine navigation based on attempt limits
|
|
303
324
|
if (currentCard.records.length >= maxAttemptsPerView) {
|
|
304
325
|
if (sessionViews >= maxSessionViews) {
|
|
305
|
-
// Too many session views - dismiss completely with ELO penalty
|
|
306
|
-
if
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
326
|
+
// Too many session views - dismiss completely with ELO penalty.
|
|
327
|
+
// Skip if this response already updated ELO above, to avoid a double
|
|
328
|
+
// penalty on the same record (happens when maxAttemptsPerView === 1).
|
|
329
|
+
if (!eloUpdated) {
|
|
330
|
+
if (taggedPerformance) {
|
|
331
|
+
// Use tagged performance for final failure
|
|
332
|
+
void this.eloService.updateUserAndCardEloPerTag(
|
|
333
|
+
taggedPerformance,
|
|
334
|
+
courseId,
|
|
335
|
+
cardId,
|
|
336
|
+
courseRegistrationDoc,
|
|
337
|
+
currentCard
|
|
338
|
+
);
|
|
339
|
+
} else {
|
|
340
|
+
void this.eloService.updateUserAndCardElo(
|
|
341
|
+
0,
|
|
342
|
+
courseId,
|
|
343
|
+
cardId,
|
|
344
|
+
courseRegistrationDoc,
|
|
345
|
+
currentCard
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
logger.info(
|
|
349
|
+
`[FirstContactElo] dismiss-failed final ELO penalty for ${cardId} ` +
|
|
350
|
+
`(historyLen=${history.records.length}, sessionViews=${sessionViews})`
|
|
314
351
|
);
|
|
315
352
|
} else {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
cardId,
|
|
320
|
-
courseRegistrationDoc,
|
|
321
|
-
currentCard
|
|
353
|
+
logger.info(
|
|
354
|
+
`[FirstContactElo] dismiss-failed — ELO already updated this response, ` +
|
|
355
|
+
`skipping double penalty for ${cardId}`
|
|
322
356
|
);
|
|
323
357
|
}
|
|
324
358
|
return {
|