@vue-skuilder/db 0.2.3 → 0.2.5

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.3",
7
+ "version": "0.2.5",
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.3",
51
+ "@vue-skuilder/common": "0.2.5",
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.3"
65
+ "stableVersion": "0.2.5"
66
66
  }
@@ -7,6 +7,13 @@ import type { QualifiedCardID } from '../..';
7
7
  import type { CardGenerator, GeneratorContext, GeneratorResult } from './types';
8
8
  import { logger } from '@db/util/logger';
9
9
 
10
+ /**
11
+ * Std-dev (in ELO points) of the Gaussian that converts card↔user ELO distance
12
+ * into a relevance weight. 300 reproduces the legacy linear ramp's half-weight
13
+ * point (distance 250 → ~0.5) while removing its hard zero beyond distance 500.
14
+ */
15
+ const ELO_RELEVANCE_SIGMA = 300;
16
+
10
17
  // ============================================================================
11
18
  // ELO NAVIGATOR
12
19
  // ============================================================================
@@ -96,17 +103,31 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
96
103
  // `[active=${activeCards.length} candidates=${newCards.length}]`
97
104
  // );
98
105
 
99
- // Score new cards by ELO distance, then apply weighted sampling without
100
- // replacement using the Efraimidis-Spirakis (A-Res) algorithm:
106
+ // Score new cards by ELO proximity, then apply bounded multiplicative
107
+ // jitter for session-to-session variety.
101
108
  //
102
- // key = U ^ (1 / rawScore) where U ~ Uniform(0, 1)
109
+ // relevance = exp(-(distance / SIGMA)^2) // Gaussian: smooth, always > 0
110
+ // score = relevance * (0.5 + 0.5 * U) // U ~ Uniform(0, 1)
103
111
  //
104
- // Sorting by key descending produces a weighted random sample: high-score
105
- // cards are still preferred, but cards with equal scores are shuffled
106
- // uniformly rather than deterministically. This prevents the same failed
107
- // cards from looping back every session when many cards share similar ELO.
112
+ // This replaces the legacy `rawScore = max(0, 1 - distance/500)` ramp +
113
+ // Efraimidis-Spirakis key `U^(1/rawScore)`, which introduced two
114
+ // discontinuities that defeated downstream replan boosts:
115
+ // 1. The ramp's clamp made every card ≥500 ELO from the user a HARD zero.
116
+ // The pipeline DELETES zero-score cards (filter score>0) *before* boosts
117
+ // are applied, so no boost could resurface an under-ELO'd target — e.g.
118
+ // a freshly-introduced grapheme sitting ~475 below an inflated global
119
+ // ELO. (See packages/db/docs/todo-intro-concept-emphasis-and-retrieval.md.)
120
+ // 2. The A-Res key `U^(1/rawScore)` ALSO manufactured effective zeros: as
121
+ // rawScore→0 the exponent explodes and `U^huge` underflows to 0, with
122
+ // wild variance just above it — so a downstream boost multiplied a
123
+ // lottery ticket rather than a stable relevance.
108
124
  //
109
- // Edge case: rawScore=0 key=0, never selected (correct exclusion).
125
+ // Gaussian relevance never hits zero (no cliff, survives the score>0 filter,
126
+ // so a boost can always lift a low-ELO target), and the [0.5, 1] jitter keeps
127
+ // ELO ordering up to a 2× factor while still shuffling near-equal cards so the
128
+ // same cards don't loop every session. SIGMA=300 reproduces the old ramp's
129
+ // half-weight point (distance 250 → ~0.5), leaving center-of-range difficulty
130
+ // matching unchanged.
110
131
  //
111
132
  // Card ELO is read from the pooled `.elo` carried on each candidate by
112
133
  // getCardsCenteredAtELO — verified equal to a separate getCardEloData()
@@ -115,8 +136,8 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
115
136
  const cardElo = c.elo ?? 1000;
116
137
 
117
138
  const distance = Math.abs(cardElo - userGlobalElo);
118
- const rawScore = Math.max(0, 1 - distance / 500);
119
- const samplingKey = rawScore > 0 ? Math.random() ** (1 / rawScore) : 0;
139
+ const relevance = Math.exp(-((distance / ELO_RELEVANCE_SIGMA) ** 2));
140
+ const samplingKey = relevance * (0.5 + 0.5 * Math.random());
120
141
 
121
142
  return {
122
143
  cardId: c.cardID,
@@ -129,7 +150,7 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
129
150
  strategyId: this.strategyId || 'NAVIGATION_STRATEGY-ELO-default',
130
151
  action: 'generated',
131
152
  score: samplingKey,
132
- reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), raw ${rawScore.toFixed(3)}, key ${samplingKey.toFixed(3)}`,
153
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), relevance ${relevance.toFixed(3)}, key ${samplingKey.toFixed(3)}`,
133
154
  },
134
155
  ],
135
156
  };
@@ -21,6 +21,12 @@ import { mergeHints } from '@db/core/navigators/Pipeline';
21
21
  import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
22
22
  import { captureMixerRun } from './MixerDebugger';
23
23
  import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
24
+ import {
25
+ registerActiveController,
26
+ type SessionDebugSnapshot,
27
+ type SessionDrawnCardDebug,
28
+ type SessionQueueDebug,
29
+ } from './SessionOverlay';
24
30
 
25
31
  // ReplanHints is defined in generators/types to avoid circular dependencies.
26
32
  // Re-exported here for backward compatibility.
@@ -58,6 +64,21 @@ export interface ReplanOptions {
58
64
  * multiply, require/exclude lists concatenate).
59
65
  */
60
66
  sessionHints?: ReplanHints;
67
+ /**
68
+ * Like `sessionHints`, but *merged* into the existing session-durable hints
69
+ * (via `mergeHints`) instead of replacing them. Use when emphasis should
70
+ * *accumulate* across replans rather than clobber — e.g. introducing a second
71
+ * concept mid-session must not wipe the first concept's boost, nor any
72
+ * `difficultyBooster`/`conceptBackoff` state on other concepts.
73
+ *
74
+ * Merge semantics (see `mergeHints`): boosts MULTIPLY, require/exclude lists
75
+ * concat-dedup. Re-emphasising the *same* tag therefore compounds — callers
76
+ * boosting a tag they may have already boosted should clamp at the call site.
77
+ *
78
+ * If both `sessionHints` and `mergeSessionHints` are supplied, the replace is
79
+ * applied first, then the merge — but they are normally mutually exclusive.
80
+ */
81
+ mergeSessionHints?: ReplanHints;
61
82
  /**
62
83
  * Maximum number of new cards to return from the pipeline.
63
84
  * Default: 20 (the standard session batch size).
@@ -236,6 +257,14 @@ export class SessionController<TView = unknown> extends Loggable {
236
257
  */
237
258
  private _replanPromise: Promise<void> | null = null;
238
259
 
260
+ /**
261
+ * Reason for the replan currently executing in `_runReplan`, surfaced by the
262
+ * debug overlay's spinner. The caller's `opts.label` when present, else
263
+ * `'(auto)'`. Only meaningful while `_replanPromise` is non-null; cleared
264
+ * when the in-flight chain settles.
265
+ */
266
+ private _activeReplanLabel: string | null = null;
267
+
239
268
  /**
240
269
  * Number of well-indicated new cards remaining before the queue
241
270
  * degrades to poorly-indicated content. Decremented on each newQ
@@ -274,6 +303,22 @@ export class SessionController<TView = unknown> extends Loggable {
274
303
  */
275
304
  private _sessionHints: ReplanHints | null = null;
276
305
 
306
+ /**
307
+ * Card IDs that have been *served* (drawn/consumed) this session. Populated
308
+ * at the single consumption choke-point (removeItemFromQueue), so it reflects
309
+ * a draw the instant it happens — earlier than `_sessionRecord`, which only
310
+ * lands once the card is *responded to*.
311
+ *
312
+ * Used to keep already-served cards out of newQ on every (re)plan: a `new`
313
+ * card shown once must never re-enter newQ this session. This is the general
314
+ * guard against re-presentation — including the case where a replan in flight
315
+ * captured a now-drawn card (e.g. a +INF require-injected follow-up the
316
+ * depletion prefetch grabbed just before it was drawn). Reviews/failed cards
317
+ * legitimately recur and are tracked by their own queues, so this only gates
318
+ * `new`-origin candidates.
319
+ */
320
+ private _servedCardIds: Set<string> = new Set();
321
+
277
322
  /**
278
323
  * Consumer-supplied hooks invoked after each question response is processed.
279
324
  * Seeded from constructor options (threaded from
@@ -372,6 +417,11 @@ export class SessionController<TView = unknown> extends Loggable {
372
417
  endTime: ${this.endTime}
373
418
  defaultBatchLimit: ${this._defaultBatchLimit}
374
419
  initialReviewCap: ${this._initialReviewCap}`);
420
+
421
+ // Expose this (now the most-recently-constructed) controller to the debug
422
+ // overlay (window.skuilder.session.dbgOverlay()). A new session overwrites
423
+ // the prior handle; no-op overhead when the overlay is never opened.
424
+ registerActiveController(this);
375
425
  }
376
426
 
377
427
  private tick() {
@@ -500,17 +550,33 @@ export class SessionController<TView = unknown> extends Loggable {
500
550
  .catch(() => undefined)
501
551
  .then(() => this._runReplan(opts));
502
552
 
503
- this._replanPromise = queued.finally(() => {
504
- if (this._replanPromise === queued) this._replanPromise = null;
553
+ // Compare against the promise we actually store. `.finally()` returns a
554
+ // NEW promise, so guarding on `=== queued` (the pre-finally promise) never
555
+ // matches and would leak _replanPromise. `tracked` is read only inside the
556
+ // async callback (after init), so the self-reference is safe.
557
+ const tracked: Promise<void> = queued.finally(() => {
558
+ if (this._replanPromise === tracked) {
559
+ this._replanPromise = null;
560
+ this._activeReplanLabel = null;
561
+ }
505
562
  });
563
+ this._replanPromise = tracked;
506
564
 
507
565
  return queued;
508
566
  }
509
567
 
510
568
  const run = this._runReplan(opts);
511
- this._replanPromise = run.finally(() => {
512
- if (this._replanPromise === run) this._replanPromise = null;
569
+ // Compare against the wrapped promise we store, not `run` — `.finally()`
570
+ // returns a new promise, so `=== run` never matches and _replanPromise
571
+ // would never clear (perpetual "replan in progress"). Safe self-reference:
572
+ // `tracked` is read only in the async callback, after initialization.
573
+ const tracked: Promise<void> = run.finally(() => {
574
+ if (this._replanPromise === tracked) {
575
+ this._replanPromise = null;
576
+ this._activeReplanLabel = null;
577
+ }
513
578
  });
579
+ this._replanPromise = tracked;
514
580
 
515
581
  await run;
516
582
  }
@@ -521,12 +587,18 @@ export class SessionController<TView = unknown> extends Loggable {
521
587
  * triggers in nextCard) return false and may coalesce.
522
588
  */
523
589
  private _replanHasIntent(opts: ReplanOptions): boolean {
524
- if (opts.label) return true;
590
+ // NOTE: `label` is intentionally NOT an intent signal. It is observability-
591
+ // only metadata (debug overlay spinner, log tags, Pipeline strategy names),
592
+ // so labelling a replan must never change scheduling. Intent is strictly
593
+ // "does this replan carry scheduling-relevant options". This lets the
594
+ // unlabeled-but-named auto-replans (auto:depletion / auto:quality) keep
595
+ // coalescing while still showing a reason in the overlay.
525
596
  if (opts.limit !== undefined) return true;
526
597
  if (opts.minFollowUpCards !== undefined) return true;
527
598
  if (opts.mode && opts.mode !== 'replace') return true;
528
599
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
529
600
  if (opts.sessionHints !== undefined) return true;
601
+ if (opts.mergeSessionHints !== undefined) return true;
530
602
  return false;
531
603
  }
532
604
 
@@ -543,6 +615,11 @@ export class SessionController<TView = unknown> extends Loggable {
543
615
  * newQ.peek(0) is the imminent draw we need to exclude.
544
616
  */
545
617
  private async _runReplan(opts: ReplanOptions): Promise<void> {
618
+ // Surface the executing replan's reason to the debug overlay spinner.
619
+ // `label` is observability-only (see _replanHasIntent); '(auto)' covers any
620
+ // unlabeled path.
621
+ this._activeReplanLabel = opts.label ?? '(auto)';
622
+
546
623
  // Exclude all cards already presented this session. The pipeline may
547
624
  // not yet see their encounter records (async writes), so without this
548
625
  // they can re-enter newQ via replaceAll and cause duplicates.
@@ -583,6 +660,14 @@ export class SessionController<TView = unknown> extends Loggable {
583
660
  );
584
661
  }
585
662
 
663
+ // Additive emphasis: merge (don't clobber) into durable hints. Lets a new
664
+ // concept's boost accumulate on top of prior concepts' boosts and any
665
+ // observer-managed boost/decay state. Boosts multiply, lists concat-dedup.
666
+ if (opts.mergeSessionHints !== undefined) {
667
+ this._sessionHints = mergeHints([this._sessionHints, opts.mergeSessionHints]) ?? null;
668
+ this.log(`[Replan] Session hints merged: ${JSON.stringify(this._sessionHints)}`);
669
+ }
670
+
586
671
  // Forward hints to all sources (CourseDB stashes them, Pipeline consumes
587
672
  // them). The one-shot `opts.hints` are merged with the durable
588
673
  // `_sessionHints` so session emphasis survives this and every later run.
@@ -638,6 +723,46 @@ export class SessionController<TView = unknown> extends Loggable {
638
723
  return this._sessionHints;
639
724
  }
640
725
 
726
+ /**
727
+ * Live state snapshot for the debug overlay (window.skuilder.session
728
+ * .dbgOverlay()). Reads directly from the private queues and hints, so it
729
+ * always reflects the current moment — unlike the passive SessionDebugger
730
+ * snapshots, which only capture what was explicitly pushed to them.
731
+ */
732
+ public getDebugSnapshot(): SessionDebugSnapshot {
733
+ const describe = <T extends { cardID: string }>(q: ItemQueue<T>): SessionQueueDebug => {
734
+ const cards: string[] = [];
735
+ for (let i = 0; i < q.length; i++) {
736
+ cards.push(q.peek(i).cardID);
737
+ }
738
+ return { length: q.length, dequeueCount: q.dequeueCount, cards };
739
+ };
740
+ const drawnCards: SessionDrawnCardDebug[] = this._sessionRecord.map((r) => {
741
+ const last = r.records[r.records.length - 1];
742
+ return {
743
+ cardID: r.item.cardID,
744
+ status: r.item.status,
745
+ attempts: r.records.length,
746
+ correct: last && isQuestionRecord(last) ? last.isCorrect : null,
747
+ timeSpentMs: r.records.reduce((sum, rec) => sum + rec.timeSpent, 0),
748
+ };
749
+ });
750
+ return {
751
+ secondsRemaining: this.secondsRemaining,
752
+ hasCardGuarantee: this.hasCardGuarantee,
753
+ minCardsGuarantee: this._minCardsGuarantee,
754
+ wellIndicatedRemaining: this._wellIndicatedRemaining,
755
+ currentCard: this._currentCard?.item.cardID ?? null,
756
+ sessionHints: this._sessionHints,
757
+ replanActive: this._replanPromise !== null,
758
+ replanLabel: this._activeReplanLabel,
759
+ reviewQ: describe(this.reviewQ),
760
+ newQ: describe(this.newQ),
761
+ failedQ: describe(this.failedQ),
762
+ drawnCards,
763
+ };
764
+ }
765
+
641
766
  /**
642
767
  * Merge `hints` into the durable session hints via the pipeline's
643
768
  * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
@@ -734,9 +859,14 @@ export class SessionController<TView = unknown> extends Loggable {
734
859
  */
735
860
  private async _replanUncoalesced(opts: ReplanOptions): Promise<void> {
736
861
  const run = this._runReplan(opts);
737
- this._replanPromise = run.finally(() => {
738
- if (this._replanPromise === run) this._replanPromise = null;
862
+ // See requestReplan: guard against the wrapped promise we store, not `run`.
863
+ const tracked: Promise<void> = run.finally(() => {
864
+ if (this._replanPromise === tracked) {
865
+ this._replanPromise = null;
866
+ this._activeReplanLabel = null;
867
+ }
739
868
  });
869
+ this._replanPromise = tracked;
740
870
  await run;
741
871
  }
742
872
 
@@ -1026,8 +1156,13 @@ export class SessionController<TView = unknown> extends Loggable {
1026
1156
  const reviewWeighted = mixedWeighted
1027
1157
  .filter((w) => getCardOrigin(w) === 'review')
1028
1158
  .slice(0, this._initialReviewCap);
1159
+ // Proactive de-dup: a `new` card served earlier this session must never
1160
+ // re-enter newQ — whether it slipped back via a +INF require-injection, an
1161
+ // additive merge, or a stale generator candidate. This is the general guard
1162
+ // (see _servedCardIds); it makes re-presentation structurally impossible
1163
+ // rather than relying on each upstream path to exclude correctly.
1029
1164
  const newWeighted = mixedWeighted
1030
- .filter((w) => getCardOrigin(w) === 'new')
1165
+ .filter((w) => getCardOrigin(w) === 'new' && !this._servedCardIds.has(w.cardId))
1031
1166
  .slice(0, newLimit);
1032
1167
 
1033
1168
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
@@ -1272,7 +1407,11 @@ export class SessionController<TView = unknown> extends Loggable {
1272
1407
  `(${otherContent} in other queues) with ${this._secondsRemaining}s remaining. ` +
1273
1408
  `Triggering background replan.`
1274
1409
  );
1275
- void this.requestReplan();
1410
+ // label is observability-only (overlay/logs); does not affect coalescing.
1411
+ // mode:'merge' preserves any already-queued cards (e.g. an undrawn
1412
+ // prescribed WST whose one-shot requireCards won't be re-asserted by this
1413
+ // bare replan) instead of replaceAll() wiping them. See mergeToFront.
1414
+ void this.requestReplan({ label: 'auto:depletion', mode: 'merge' });
1276
1415
  }
1277
1416
 
1278
1417
  // Opportunistic quality: few well-indicated cards remain.
@@ -1288,7 +1427,8 @@ export class SessionController<TView = unknown> extends Loggable {
1288
1427
  `[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining ` +
1289
1428
  `(newQ: ${this.newQ.length}). Triggering background replan.`
1290
1429
  );
1291
- void this.requestReplan();
1430
+ // label is observability-only (overlay/logs); does not affect coalescing.
1431
+ void this.requestReplan({ label: 'auto:quality' });
1292
1432
  }
1293
1433
 
1294
1434
  if (this._secondsRemaining <= 0 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
@@ -1510,6 +1650,21 @@ export class SessionController<TView = unknown> extends Loggable {
1510
1650
  * Remove an item from its source queue after consumption by nextCard().
1511
1651
  */
1512
1652
  private removeItemFromQueue(item: StudySessionItem): void {
1653
+ // Durable-until-drawn requirements: a caller may place a card ID in the
1654
+ // session-durable `requireCards` (via mergeSessionHints) so every later
1655
+ // replan re-asserts it at +INF until it actually surfaces — e.g. a
1656
+ // prescribed intro follow-up that must survive the replace-mode burst/auto
1657
+ // replans that would otherwise clobber a one-shot requirement. The
1658
+ // requirement is satisfied the instant the card is drawn, so clear it here
1659
+ // (the single consumption choke-point) to stop it being re-injected forever
1660
+ // and to bound accumulation. Generic: card-ID only, no domain vocabulary.
1661
+ this._clearDurableRequirement(item.cardID);
1662
+
1663
+ // Record the draw immediately (earlier than _sessionRecord, which waits for
1664
+ // a response) so getWeightedContent can keep this card out of newQ on any
1665
+ // replan that lands after this draw.
1666
+ this._servedCardIds.add(item.cardID);
1667
+
1513
1668
  // Check each queue - item should be at the front of one of them
1514
1669
  if (this.reviewQ.peek(0)?.cardID === item.cardID) {
1515
1670
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
@@ -1523,6 +1678,28 @@ export class SessionController<TView = unknown> extends Loggable {
1523
1678
  }
1524
1679
  }
1525
1680
 
1681
+ /**
1682
+ * Remove a satisfied card ID from the durable session-hint `requireCards`
1683
+ * list. Called when a card is consumed (see removeItemFromQueue). No-op if
1684
+ * the card was not a durable requirement.
1685
+ *
1686
+ * Matches literal IDs only: a glob/pattern requirement (which may stand for
1687
+ * several cards) is NOT considered satisfied by a single draw and is left in
1688
+ * place — durable patterns are the caller's responsibility, one-shot `hints`
1689
+ * remain the right tool for them.
1690
+ */
1691
+ private _clearDurableRequirement(cardID: string): void {
1692
+ const req = this._sessionHints?.requireCards;
1693
+ if (!req || req.length === 0) return;
1694
+ const next = req.filter((id) => id !== cardID);
1695
+ if (next.length === req.length) return; // not a durable requirement
1696
+ this._sessionHints = {
1697
+ ...this._sessionHints!,
1698
+ requireCards: next.length > 0 ? next : undefined,
1699
+ };
1700
+ this.log(`[Replan] Durable requirement satisfied & cleared on draw: ${cardID}`);
1701
+ }
1702
+
1526
1703
  /**
1527
1704
  * End the session and record learning outcomes.
1528
1705
  *
@@ -1,5 +1,6 @@
1
1
  import { logger } from '../util/logger';
2
2
  import { clearRunHistory as clearPipelineRunHistory } from '../core/navigators/PipelineDebugger';
3
+ import { toggleSessionOverlay } from './SessionOverlay';
3
4
 
4
5
  // ============================================================================
5
6
  // SESSION DEBUGGER
@@ -344,6 +345,14 @@ export const sessionDebugAPI = {
344
345
  showCurrentQueue();
345
346
  },
346
347
 
348
+ /**
349
+ * Toggle the pinned, live-updating DOM overlay for the active controller
350
+ * (queues, session hints, timer). No-ops in non-browser hosts.
351
+ */
352
+ dbgOverlay(): void {
353
+ toggleSessionOverlay();
354
+ },
355
+
347
356
  /**
348
357
  * Show presentation history for current or past session.
349
358
  */
@@ -413,6 +422,7 @@ export const sessionDebugAPI = {
413
422
  🎯 Session Debug API
414
423
 
415
424
  Commands:
425
+ .dbgOverlay() Toggle the pinned live overlay (queues, hints, timer)
416
426
  .showQueue() Show current queue state (active session only)
417
427
  .showHistory(index?) Show presentation history (0=current/last, 1=previous, etc)
418
428
  .showInterleaving(index?) Analyze course interleaving pattern