@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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.10",
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.10",
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.10"
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 whether reviews being out-competed by
7
- // (boosted) new cards is *temporary* (the backlog multiplier still has headroom
8
- // to climb and lift reviews) or *boost-driven* (the multiplier is maxed and the
9
- // new-card boosts simply sit higher only a hint relaxation reorders them).
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 → max). */
27
+ /** Global multiplier applied to every due review's urgency this run (>= 1.0, unbounded). */
27
28
  backlogMultiplier: number;
28
- /** Max achievable backlog multiplier (the cap), for headroom context. */
29
- maxBacklogMultiplier: number;
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
- * Maximum backlog pressure as a *multiplier* on review urgency.
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, scaling up as the
48
- * due pile grows, maxing here at 3× healthy backlog). It replaces an older
49
- * additive +0..+0.5 term that was a [0,1]-era modifier once review scores
50
- * stopped being clamped to 1.0 and new cards could be boosted well past it
51
- * (e.g. an intro ×5 7+), a flat +0.5 was both too small to compete and mostly
52
- * eaten by the old 1.0 clamp. A multiplier scales review priority onto the same
53
- * open scale the boosted new cards live on, so a heavy backlog can genuinely
54
- * lift reviews into competition. Tunable verify review vs new ordering in the
55
- * dbg overlay's "review backpressure" panel.
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 MAX_BACKLOG_MULTIPLIER = 2.0;
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
- maxBacklogMultiplier: MAX_BACKLOG_MULTIPLIER,
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), increasing linearly above
272
- * it and maxing out at MAX_BACKLOG_MULTIPLIER at the healthy backlog.
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, MAX_BACKLOG_MULTIPLIER=2.0):
275
- * - 10 due reviews → ×1.00 (healthy)
276
- * - 20 due reviews → ×1.00 (at threshold)
277
- * - 40 due reviews → ×1.50 (2x threshold)
278
- * - 60 due reviews → ×2.00 (3x threshold, maxed)
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 to MAX_BACKLOG_MULTIPLIER)
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 multiplier = 1 + (excess / this.healthyBacklog) * ((MAX_BACKLOG_MULTIPLIER - 1) / 2);
302
+ const ratio = excess / this.healthyBacklog;
291
303
 
292
- return Math.min(MAX_BACKLOG_MULTIPLIER, multiplier);
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 → up to MAX_BACKLOG_MULTIPLIER at ).
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; what keeps them from running away is the bounded multiplier, not
315
- * a hard ceiling.
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 to MAX_BACKLOG_MULTIPLIER)
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 → max) on review urgency; reviews are
369
- * no longer clamped to 1.0, so under a heavy backlog they scale up to compete
370
- * with (and exceed) new cards. So:
371
- * - multiplier climbing, below max reviews will rise as the backlog grows
372
- * (temporary they'll start winning slots);
373
- * - multiplier maxed but still out-competed the boosts in `sessionHints`
374
- * simply sit higher; reviews only resurface once those relax (backoff), not
375
- * from more backlog. Compare `top review` here against the supplyQ head score.
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 maxed = b.backlogMultiplier >= b.maxBacklogMultiplier - 1e-9;
382
- const multColor = b.backlogMultiplier <= 1 ? '#86efac' : maxed ? '#fca5a5' : '#fcd34d';
383
- const headroom = maxed
384
- ? 'maxed — boosts decide order until they relax'
385
- : b.backlogMultiplier > 1
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)}/${b.maxBacklogMultiplier.toFixed(2)}</span> ` +
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 maxed = b.backlogMultiplier >= b.maxBacklogMultiplier - 1e-9;
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
- `${b.maxBacklogMultiplier.toFixed(2)} ${headroom}; top review ${top}`
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
- `[ResponseProcessor] per-tag ELO update for ${cardId}: scored=[${scoredTags.join(', ')}] count-only=[${nullTags.join(', ')}]`
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
- '[ResponseProcessor] Processed correct response with SRS scheduling and ELO update'
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
- // Update ELO for first-time failures (not subsequent attempts on same card)
274
- if (history.records.length !== 1 && cardRecord.priorAttemps === 0) {
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
- `[ResponseProcessor] Processed incorrect response with per-tag ELO update (${Object.keys(taggedPerformance).length - 1} tags)`
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('[ResponseProcessor] Processed incorrect response with ELO update');
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('[ResponseProcessor] Processed incorrect response (no ELO update needed)');
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 (taggedPerformance) {
307
- // Use tagged performance for final failure
308
- void this.eloService.updateUserAndCardEloPerTag(
309
- taggedPerformance,
310
- courseId,
311
- cardId,
312
- courseRegistrationDoc,
313
- currentCard
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
- void this.eloService.updateUserAndCardElo(
317
- 0,
318
- courseId,
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 {