@vue-skuilder/db 0.2.0 → 0.2.2

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.
@@ -0,0 +1,418 @@
1
+ # Session Lifecycle & Replanning (as-built)
2
+
3
+ > **Status:** Descriptive map of current behavior, for theory-building and review.
4
+ > The core sections (Cast → Consumer extension points) are consumer-agnostic and
5
+ > framework-doc-ready. The final **Performance notes** section is living,
6
+ > internal/diagnostic commentary on the *current* implementation — it folds in
7
+ > the former `agent/session-pipeline-perf` assessment and should be trimmed
8
+ > before this ships as public framework documentation.
9
+ >
10
+ > **Perf work is parked (2026-05).** The felt mid-session jank (the synchronous
11
+ > wedge-breaker firing when the queue bottomed out) is resolved by the cache work
12
+ > recorded below. The `[perf]` timing instrumentation is commented out in place
13
+ > (search `[perf] parked` in the source); uncomment to re-measure. Remaining
14
+ > threads (cold spin-up, possibly-remote reads) are documented but not pursued.
15
+
16
+ This document maps how `SessionController` drives content selection through the
17
+ navigation `Pipeline` during a study session: what runs at session start, when
18
+ the pipeline re-runs ("replans"), how concurrent replans are reconciled, and
19
+ where consumer applications hook in.
20
+
21
+ It is a companion to `navigators-architecture.md` (which describes the
22
+ generator/filter pipeline itself). This doc describes the *driver* around it.
23
+
24
+ ---
25
+
26
+ ## Cast
27
+
28
+ | Component | Role | Layer |
29
+ |-----------|------|-------|
30
+ | `SessionController` | Owns the session clock + three queues; decides what card is next; triggers replans | framework |
31
+ | `StudyContentSource` | A content source. Production impl is `CourseDB` (one per course in the session) | framework |
32
+ | `CourseDB.getWeightedCards()` | Builds a navigator and runs it | framework |
33
+ | `CourseDB.createNavigator()` | Reads strategy docs, assembles a `Pipeline` | framework |
34
+ | `Pipeline` | generator → tag hydration → filters → hints → sort → top-N | framework |
35
+ | `SourceMixer` | Interleaves candidates from multiple sources | framework |
36
+ | `CardHydrationService` | Turns selected items into render-ready cards (view component + data) | framework |
37
+ | Consumer card views | May call `requestReplan()` with hints (e.g. pedagogical intros) | **consumer** |
38
+
39
+ The three queues live on `SessionController`:
40
+
41
+ - **reviewQ** — SRS-due cards. Filled once at start, drained by consumption, **never refilled** mid-session.
42
+ - **newQ** — new content. Refilled by replans.
43
+ - **failedQ** — cards failed this session, for end-of-session cleanup.
44
+
45
+ ---
46
+
47
+ ## Two distinct pipeline entry points
48
+
49
+ Everything expensive happens inside `Pipeline.getWeightedCards()`. It is reached
50
+ two ways:
51
+
52
+ 1. **Initial planning** — `prepareSession()` → `getWeightedContent()` once.
53
+ 2. **Replan** — `requestReplan()` → `_executeReplan()` → `getWeightedContent({replan:true})`, any number of times mid-session.
54
+
55
+ Both call `source.getWeightedCards(limit)` on every source. **There is no
56
+ separate "cheap" path** — a replan runs the same machinery as initial planning.
57
+
58
+ ---
59
+
60
+ ## Sequence: session start
61
+
62
+ ```mermaid
63
+ sequenceDiagram
64
+ participant App as Consumer (Study view)
65
+ participant SC as SessionController
66
+ participant Src as CourseDB (source)
67
+ participant Nav as createNavigator
68
+ participant Pipe as Pipeline
69
+ participant Mix as SourceMixer
70
+ participant Hyd as CardHydrationService
71
+
72
+ App->>SC: prepareSession()
73
+ SC->>SC: getWeightedContent() (fetchLimit = newLimit + initialReviewCap)
74
+ loop per source
75
+ SC->>Src: getWeightedCards(fetchLimit)
76
+ Src->>Nav: createNavigator(user)
77
+ Nav->>Nav: getAllNavigationStrategies() (DB query)
78
+ Nav->>Nav: PipelineAssembler.assemble(...)
79
+ Nav-->>Src: new Pipeline (empty caches)
80
+ Src->>Pipe: getWeightedCards(fetchLimit)
81
+ Pipe->>Pipe: buildContext() (user ELO + orchestration ctx)
82
+ Pipe->>Pipe: generator.getWeightedCards(500)
83
+ Pipe->>Pipe: hydrateTags(~500 cards) (batch DB query)
84
+ Pipe->>Pipe: filters.transform() x N
85
+ Pipe->>Pipe: applyHints() (if any) → sort → slice(limit)
86
+ Pipe-->>Src: WeightedCard[]
87
+ Src-->>SC: GeneratorResult
88
+ end
89
+ SC->>Mix: mix(batches)
90
+ Mix-->>SC: interleaved candidates
91
+ SC->>SC: split by origin → reviewQ (≤cap), newQ (≤newLimit)
92
+ SC->>Hyd: ensureHydratedCards() (top N of each queue)
93
+ Hyd-->>SC: render-ready cards cached
94
+ SC-->>App: ready (timer starts)
95
+ ```
96
+
97
+ Key parameters (`SessionController` constructor options):
98
+
99
+ - `defaultBatchLimit` (default **20**) — newQ target size.
100
+ - `initialReviewCap` (default **200**) — max reviews loaded at start.
101
+ - On init, `fetchLimit = defaultBatchLimit + initialReviewCap` (e.g. **220**) is
102
+ passed to each source so reviews and new cards can both fill from one fetch.
103
+
104
+ ---
105
+
106
+ ## Sequence: serving a card (`nextCard`) and its replan triggers
107
+
108
+ `nextCard()` dismisses the current card, then runs **three** replan triggers
109
+ before drawing the next card. The framework comments classify them precisely:
110
+
111
+ ```mermaid
112
+ flowchart TD
113
+ A[nextCard called] --> B[dismiss current card]
114
+ B --> C{queues all empty<br/>AND replan in flight?}
115
+ C -- yes --> C1[await in-flight replan]
116
+ C -- no --> D
117
+ C1 --> D
118
+
119
+ D{newQ length ≤ 3<br/>AND time left<br/>AND no replan in flight?}
120
+ D -- yes --> D1[["(a) OPPORTUNISTIC depletion<br/>void requestReplan() — background"]]
121
+ D -- no --> E
122
+ D1 --> E
123
+
124
+ E{wellIndicated ≤ 3<br/>AND newQ > 0<br/>AND not suppressed<br/>AND no replan in flight?}
125
+ E -- yes --> E1[["(a) OPPORTUNISTIC quality<br/>void requestReplan() — background"]]
126
+ E -- no --> F
127
+ E1 --> F
128
+
129
+ F{time up AND<br/>failedQ empty AND<br/>no guarantee?}
130
+ F -- yes --> F1[return null — session ends]
131
+ F -- no --> G
132
+
133
+ G{all queues empty<br/>AND time left?}
134
+ G -- yes --> G1[["(b) LOAD-BEARING wedge-breaker<br/>await _replanUncoalesced — SYNCHRONOUS<br/>bounded retry, then give up"]]
135
+ G -- no --> H
136
+ G1 --> G
137
+
138
+ H[select next item from a queue] --> I[hydrate if needed, draw, return card]
139
+ ```
140
+
141
+ ### The trigger taxonomy (from the source comments)
142
+
143
+ - **(a) Opportunistic prefetch** — `depletion` (newQ ≤ `DEPLETION_PREFETCH_THRESHOLD` = 3)
144
+ and `quality` (`_wellIndicatedRemaining` ≤ buffer). Fire-and-forget (`void`),
145
+ may coalesce, may no-op. Their job is to make replans happen *early*. **A
146
+ missed opportunistic replan is acceptable** — it's a perf optimization.
147
+ - **(b) Load-bearing wedge-breaker** — if the clock is ticking and we'd serve
148
+ `null`, the pipeline runs **synchronously** in the draw path. Bypasses
149
+ coalescing. This is the only *correctness* guarantee. **A missed wedge-breaker
150
+ is a stall/wedge.**
151
+
152
+ > Design rule from the code: *"a redundant pipeline run is a perf bug, a missing
153
+ > pipeline run is a correctness bug. Bias toward the cheaper failure."*
154
+
155
+ The user-perceptible **in-session pause** is the wedge-breaker firing
156
+ synchronously — i.e. the opportunistic prefetch already lost the race to the
157
+ user emptying the queue.
158
+
159
+ ---
160
+
161
+ ## Replan lifecycle (concurrency state machine)
162
+
163
+ `requestReplan()` reconciles concurrent replans. The distinction that drives the
164
+ machine is **intent**: a bare auto-replan (no label/limit/hints/guarantee) has
165
+ no intent and may coalesce; anything carrying caller intent must not be dropped.
166
+
167
+ ```mermaid
168
+ stateDiagram-v2
169
+ [*] --> Idle
170
+
171
+ Idle --> Running: requestReplan()<br/>_replanPromise = run
172
+
173
+ Running --> Running: requestReplan() (no intent)<br/>COALESCE → return same promise
174
+ Running --> Queued: requestReplan() (has intent)<br/>chain AFTER in-flight run
175
+
176
+ Queued --> Running: in-flight resolves<br/>queued run starts
177
+ Running --> Idle: run resolves, no successor
178
+
179
+ note right of Running
180
+ Wedge-breaker uses _replanUncoalesced():
181
+ bypasses this machine, forces a FRESH run,
182
+ still tracks _replanPromise for observers.
183
+ end note
184
+ ```
185
+
186
+ Has-intent test (`_replanHasIntent`): true if any of `label`, `limit`,
187
+ `minFollowUpCards`, non-`replace` `mode`, or non-empty `hints`.
188
+
189
+ ### Side-effects a replan can set
190
+
191
+ | Flag / counter | Set by | Effect |
192
+ |----------------|--------|--------|
193
+ | `_suppressQualityReplan` | burst replan (`limit < defaultBatchLimit`) | mutes the (a)-quality trigger so a small hinted queue isn't clobbered before consumption; cleared by depletion trigger |
194
+ | `_minCardsGuarantee` | replan with `minFollowUpCards` | timer cannot end session until this many more cards served; lets an intro card surface near session end with guaranteed follow-up |
195
+ | `_wellIndicatedRemaining` | every plan/replan | count of cards scoring ≥ `WELL_INDICATED_SCORE` (0.10); drives (a)-quality |
196
+
197
+ ### Auto-exclude on every replan
198
+
199
+ `_runReplan()` always excludes, via `hints.excludeCards`: the current card, every
200
+ card already in `_sessionRecord`, and `newQ.peek(0)` (the imminent draw). This
201
+ prevents the just-drawn / about-to-draw card from being re-seated at the head of
202
+ a freshly-replaced newQ and shown twice.
203
+
204
+ ---
205
+
206
+ ## Consumer extension points
207
+
208
+ This is where applications (e.g. LettersPractice) shape the session. The
209
+ framework surface is small:
210
+
211
+ 1. **`SessionController.requestReplan(options)`** — request a mid-session replan.
212
+ `ReplanOptions`: `hints`, `limit`, `mode` (`replace`|`merge`),
213
+ `minFollowUpCards`, `label`.
214
+ 2. **`ReplanHints`** (forwarded to the pipeline, applied *after* the filter
215
+ chain, one-shot): `boostTags`, `boostCards`, `requireTags`, `requireCards`,
216
+ `excludeTags`, `excludeCards`. Tag/card patterns support `*` globs.
217
+ 3. **`source.setEphemeralHints()`** — how the controller threads hints to each
218
+ source for the next run. `CourseDB` stashes them; `Pipeline` consumes and
219
+ clears them after one run.
220
+
221
+ Hint application order in the pipeline (`applyHints`): **exclude → boost →
222
+ require**. `requireCards` is a hard guarantee — required cards are injected into
223
+ the result with `+Infinity` score even if a filter zeroed them, and are
224
+ pre-fetched into the pool if the generator never produced them.
225
+
226
+ ### Worked example: LettersPractice GPC-intro (a "burst + follow-up" replan)
227
+
228
+ When a phonics intro card completes, the consumer view fires a replan that
229
+ *reshapes* the immediate future of the session:
230
+
231
+ ```ts
232
+ // GpcIntroView.vue, on intro completion
233
+ emit('request-replan', {
234
+ label: `gpc-intro-${letter}-complete`,
235
+ hints: {
236
+ boostTags: { [exerciseTag]: 10.0 }, // push the matching exercise
237
+ requireCards: [followUpCardId], // guarantee the "who said that?" follow-up
238
+ excludeTags: ['gpc:intro:*'], // don't surface another intro right now
239
+ },
240
+ limit: 3, // BURST: tiny queue
241
+ mode: 'replace',
242
+ minFollowUpCards: 2, // guarantee 2 cards even if the timer is nearly up
243
+ });
244
+ ```
245
+
246
+ This composes several framework mechanisms at once:
247
+
248
+ - `limit: 3` → burst replan → sets `_suppressQualityReplan` so the background
249
+ quality trigger won't immediately clobber these 3 hinted cards.
250
+ - `requireCards` → the follow-up card is force-injected even if filters/ELO
251
+ wouldn't have ranked it.
252
+ - `minFollowUpCards: 2` → sets `_minCardsGuarantee`, so the intro's practice
253
+ actually gets shown rather than the session ending on the intro.
254
+ - `excludeTags: ['gpc:intro:*']` → prevents back-to-back intros.
255
+
256
+ It is submitted *before* the intro animation plays out ("submit + replan early
257
+ so the planner works while the words play") — an explicit attempt to hide
258
+ pipeline latency behind animation time.
259
+
260
+ ### Best practices for consumers
261
+
262
+ - **Submit and replan _early_, behind cover.** Fire `requestReplan()` as soon as
263
+ intent is known (e.g. on card submit, before an animation finishes) so the
264
+ pipeline runs during otherwise-idle time. The GPC-intro example does this
265
+ deliberately — the planner is latency-bound, so give it slack.
266
+ - **Treat opportunistic replans as best-effort.** A missed `(a)` prefetch is a
267
+ perf hiccup, not a bug. Never encode correctness ("this card _must_ appear") in
268
+ a bare auto-replan — it can be silently coalesced. Carry **intent** (`label`,
269
+ `limit`, `hints`, `minFollowUpCards`) so the request is preserved.
270
+ - **Use `requireCards` for hard guarantees, not `boostTags`.** Boosts only bias
271
+ ranking; `requireCards` force-injects with `+Infinity` even past a zeroing
272
+ filter, and pre-fetches the card if the generator never produced it.
273
+ - **Guard end-of-session intros with `minFollowUpCards`.** A late intro card
274
+ without a follow-up guarantee can end the session on the intro itself.
275
+ - **Small `limit` shrinks the queue, not the work.** A `limit: 3` burst still
276
+ fetches ~500 candidates and runs every filter (see the fetchLimit note below).
277
+ Use bursts to _shape_ the near-future queue, not as a speed optimization — and
278
+ note a burst sets `_suppressQualityReplan`, so it temporarily mutes the
279
+ quality trigger.
280
+ - **Don't replan per keystroke/answer.** Replans coalesce but aren't free; lean
281
+ on the controller's built-in depletion/quality triggers for routine refills.
282
+
283
+ ---
284
+
285
+ ## Performance notes (living — internal/diagnostic; trim for public framework doc)
286
+
287
+ > Folds in the former `session-pipeline-perf` living assessment. Observations
288
+ > about the *current* implementation; diagnostic, not part of the stable design.
289
+ > Snapshot: 2026-05.
290
+
291
+ ### Resolved: the felt mid-session jank
292
+
293
+ The reported symptom was UX jank — pauses once the session queues bottomed out.
294
+ Root cause: the **synchronous wedge-breaker** (see the `nextCard` trigger
295
+ taxonomy) firing in the draw path because the *background* replan lost the
296
+ consumption race. The opportunistic prefetch couldn't keep ahead because every
297
+ pipeline run was cold (caches defeated, hotspot below). Three changes closed it:
298
+
299
+ | Fix | Commit | Effect |
300
+ |-----|--------|--------|
301
+ | ELO-pool session cache in `getCardsCenteredAtELO` (fetch the broad neighbor pool once, re-rank in memory against the live ELO) | `b43dec1d` | recenter **2621ms -> 14ms** |
302
+ | Cache the constructed navigator (`_getCachedNavigator`) so the `Pipeline` instance — and its `_tagCache` / `_cachedOrchestration` — persist across replans | `1a0b1e0b` | retires "every run is cold" |
303
+ | Score from the pooled `c.elo`; delete the redundant `getCardEloData()` 500-doc `allDocs` | `e79bf619` | ~330ms off every ELOgen |
304
+
305
+ These shortened the warm replan (~2.6s) enough that it wins the race against
306
+ consumption. Observed sessions now report `wedgeRuns=0 awaitedReplan=false`. The
307
+ wedge path stays as the correctness backstop, but is rare-to-absent under normal
308
+ pace.
309
+
310
+ > **Caveat — not fully proven.** This is confirmed by jank-free logs, but we have
311
+ > not yet captured a *normal-pace* session with `wedgeRuns>0` to study the
312
+ > remaining trigger (or confirm it never fires). Don't re-optimize the wedge path
313
+ > on the assumption it's still hot; do capture such a log before declaring it
314
+ > closed for good.
315
+
316
+ ### The two clocks (a mental model worth keeping)
317
+
318
+ Latency is only *felt* in proportion to whether the user waits on it. Every stage
319
+ sits on one of two clocks:
320
+
321
+ - **Blocking clock — felt 1:1.** User is staring at the screen with nothing to do.
322
+ Cold first plan / spin-up, first-card hydrate, and the wedge-breaker *when it
323
+ fires*.
324
+ - **Race clock — felt only past a budget.** Runs ahead of the user; absolute
325
+ latency is invisible until it exceeds its slack. Background replan (deep slack
326
+ ~ 3-card depletion lead), background hydration (thin slack), already-hydrated
327
+ `nextCard` draws (trivial).
328
+
329
+ | Stage | Cost | Clock | Slack | How felt |
330
+ |-------|------|-------|-------|----------|
331
+ | Cold spin-up (ELO reindex ~2.5s of it) | ~4.6s | **Blocking** | none | **1:1, maximal** |
332
+ | Background hydration | 1-2s/card | Race | thin | at the margin |
333
+ | Background replan | ~2.6s | Race | deep (~3 cards) | rarely felt |
334
+
335
+ The cache work above optimized the *least* time-sensitive stage (background
336
+ replan) — correctly, since that is what keeps us out of the wedge. The *most*
337
+ time-sensitive stage (cold spin-up, dominated by the ELO view reindex) is
338
+ essentially untouched.
339
+
340
+ ### Open / parked threads (running notes)
341
+
342
+ - **Cold spin-up (~4.6s, blocking)** dominated by the first `elo` view reindex
343
+ (`getCardsByELO below~2490ms`). Most time-sensitive, least touched. A cheap
344
+ micro-fix worth trying: fire a throwaway `db.query('elo', {limit:1})` early
345
+ (e.g. on the study-hub view) to keep the IndexedDB index warm before
346
+ `/study/practice`.
347
+ - **Reads may be hitting REMOTE CouchDB, not the local replica** — the leading
348
+ hypothesis for the remaining costs (see next subsection). If true, this is one
349
+ root cause behind spin-up + per-card hydration + `regDoc`, not three.
350
+ - **Per-card hydration `cardDoc+tags` = 0.8-2.1s each**, a persistent band (not
351
+ just at startup). Entirely `getCourseDoc` + `getAppliedTagsBatch`. On a *local*
352
+ PouchDB these should be tens of ms — hence the remote-reads suspicion.
353
+ - **`getPendingReviews` ~ 768ms**, recomputed fresh on every replan (SRSgen).
354
+ Candidate to incrementalize, but reviews mutate as cards are answered, so
355
+ correctness needs care.
356
+ - **`regDoc ~ 444ms`** — a single registration-doc read inside
357
+ `getCardsCenteredAtELO`. Same "reads may be remote" smell.
358
+
359
+ ### The likely root cause: reads may not be local
360
+
361
+ `CourseDB` picks local-vs-remote **once, at construction**: `this.db = localDB ??
362
+ remoteDB`, where `localDB` is non-null only if `CourseSyncService` sync state is
363
+ exactly `'ready'` at that instant. Miss that window (cold first visit:
364
+ replication + view-warming takes seconds) and the whole session's pipeline reads
365
+ go to the remote handle and **never re-bind** after sync finishes.
366
+
367
+ The "remote" CouchDB in dev is `localhost:5984`, so the cost is *not* network
368
+ latency — it is **per-request work the CouchDB server does**: MapReduce view-index
369
+ maintenance. The log fingerprints it: the same `elo` view queried back-to-back
370
+ reads `below=2490ms` then `above=111ms` — cold reindex then warm, not latency
371
+ (which would tax both equally). What invalidates the index? *This session's own
372
+ ELO writes* — every answered card writes a card-ELO doc, bumping `update_seq`. A
373
+ local sync replica is one-shot, read-only and pre-warmed, so local reads never
374
+ trigger a reindex.
375
+
376
+ To confirm: add a one-line `CourseDB` constructor log (`db=local|remote`); split
377
+ the hydration timer into `cardDoc` (`.get`, no view) vs `tags`
378
+ (`getAppliedTagsBatch`, a view query) — if `tags` carries the 1-2s it's the
379
+ reindex story. The fix is then *not* micro-optimizing reads but guaranteeing the
380
+ session runs against a ready local replica (await `'ready'`; re-bind or
381
+ lazy-resolve `this.db`; reuse hydration handles).
382
+
383
+ ### Still-true structural notes
384
+
385
+ 1. **Every pipeline run *was* cold (RESOLVED by `1a0b1e0b`).** `getWeightedCards`
386
+ used to call `createNavigator(user)` per invocation, building a new `Pipeline`
387
+ with empty `_cachedOrchestration` / `_tagCache` every time. The cached
388
+ navigator now preserves both across replans within a session.
389
+ 2. **`fetchLimit` is hardcoded to 500 regardless of `limit`.**
390
+ `Pipeline.getWeightedCards(limit)` always pulls ~500 candidates, hydrates
391
+ their tags, and runs every filter over all 500, slicing to `limit` only at the
392
+ end. A `limit: 3` burst does the same heavy work as a full plan.
393
+ 3. **Initial fetch is inflated to ~220** (`defaultBatchLimit + initialReviewCap`),
394
+ compounding (2) at spin-up.
395
+
396
+ ### Instrumentation (parked)
397
+
398
+ The `[perf]` timing logs across `Pipeline`, `SessionController`,
399
+ `CardHydrationService`, `elo`/`srs` generators, and `courseDB` are commented out
400
+ in place — search **`[perf] parked`**. Uncomment to re-measure. The pre-existing
401
+ `[Pipeline:timing]` summary line is kept (it predates this effort). Two
402
+ provenance fields added during the dig — `awaitedReplan` and `wedgeRuns` on the
403
+ `nextCard` log — are the instruments to re-enable first if the wedge question is
404
+ revisited.
405
+
406
+ ## File reference
407
+
408
+ | File | Relevant region |
409
+ |------|-----------------|
410
+ | `db/src/study/SessionController.ts` | queues, `requestReplan`, `_runReplan`, `nextCard` triggers, wedge-breaker |
411
+ | `db/src/impl/couch/courseDB.ts` | `getWeightedCards`, `setEphemeralHints`, `createNavigator` (no cache) |
412
+ | `db/src/core/navigators/Pipeline.ts` | `getWeightedCards` (fetchLimit=500), `applyHints`, `hydrateTags`, `buildContext` |
413
+ | `db/src/core/navigators/generators/types.ts` | `ReplanHints`, `GeneratorResult` |
414
+ | `db/src/study/SourceMixer.ts` | `QuotaRoundRobinMixer` |
415
+ | `db/src/study/services/CardHydrationService.ts` | render-ready card cache |
416
+ | `db/src/study/SessionDebugger.ts` | per-draw / per-replan queue snapshots |
417
+ | consumer: `letterspractice/src/questions/GpcIntroView.vue` | burst + follow-up replan example |
418
+ | consumer: `letterspractice/src/services/lessons.ts` | `getPostLessonHints()` |
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.2.0",
7
+ "version": "0.2.2",
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.0",
51
+ "@vue-skuilder/common": "0.2.2",
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.0"
65
+ "stableVersion": "0.2.2"
66
66
  }
@@ -99,10 +99,16 @@ export const userDBDebugAPI = {
99
99
  */
100
100
  async showScheduledReviews(courseId?: string): Promise<void> {
101
101
  const userDB = getUserDB();
102
- if (!userDB) return;
102
+ if (!userDB) {
103
+ logger.info('[UserDB Debug] Data layer not available');
104
+ return;
105
+ }
106
+
107
+ logger.info(`[UserDB Debug] Fetching pending reviews${courseId ? ` for course: ${courseId}` : ''}...`);
103
108
 
104
109
  try {
105
110
  const reviews = await userDB.getPendingReviews(courseId);
111
+ logger.info(`[UserDB Debug] Got ${reviews.length} reviews`);
106
112
 
107
113
  // eslint-disable-next-line no-console
108
114
  console.group(`📅 Scheduled Reviews${courseId ? ` (${courseId})` : ''}`);
@@ -443,10 +443,14 @@ export class Pipeline extends ContentNavigator {
443
443
  .map((c) => c.cardId)
444
444
  );
445
445
  const filterImpacts: FilterImpact[] = [];
446
+ // [perf] parked 2026-05 (pipeline-docs-workup) — uncomment to re-measure
447
+ // const filterTimings: string[] = [];
446
448
  for (const filter of this.filters) {
449
+ // const tFilterStart = performance.now();
447
450
  const beforeCount = cards.length;
448
451
  const beforeScores = new Map(cards.map((c) => [c.cardId, c.score]));
449
452
  cards = await filter.transform(cards, context);
453
+ // filterTimings.push(`${filter.name}=${(performance.now() - tFilterStart).toFixed(0)}ms`);
450
454
 
451
455
  // Count boost/penalize/pass/removed for this filter
452
456
  let boosted = 0, penalized = 0, passed = 0;
@@ -66,6 +66,7 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
66
66
  * @param context - Optional GeneratorContext (used when called via Pipeline)
67
67
  */
68
68
  async getWeightedCards(limit: number, context?: GeneratorContext): Promise<GeneratorResult> {
69
+ // const tElo0 = performance.now(); // [perf] parked
69
70
  // Determine user ELO - from context if available, otherwise fetch
70
71
  let userGlobalElo: number;
71
72
  if (context?.userElo !== undefined) {
@@ -75,18 +76,25 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
75
76
  const userElo = toCourseElo(courseReg.elo);
76
77
  userGlobalElo = userElo.global.score;
77
78
  }
79
+ // const tUser = performance.now(); // [perf] parked
78
80
 
79
81
  const activeCards = await this.user.getActiveCards();
82
+ // const tActive = performance.now(); // [perf] parked
80
83
  const newCards = (
81
84
  await this.course.getCardsCenteredAtELO(
82
85
  { limit, elo: 'user' },
83
86
  (c: QualifiedCardID) => !activeCards.some((ac) => c.cardID === ac.cardID)
84
87
  )
85
88
  ).map((c) => ({ ...c, status: 'new' as const }));
86
-
87
- // Get ELO data for all cards in one batch
88
- const cardIds = newCards.map((c) => c.cardID);
89
- const cardEloData = await this.course.getCardEloData(cardIds);
89
+ // const tCentered = performance.now(); // [perf] parked
90
+ // [perf] parked 2026-05 (pipeline-docs-workup) uncomment to re-measure
91
+ // logger.info(
92
+ // `[perf][ELOgen] total=${(tCentered - tElo0).toFixed(0)}ms ` +
93
+ // `(userElo=${(tUser - tElo0).toFixed(0)} ` +
94
+ // `activeCards=${(tActive - tUser).toFixed(0)} ` +
95
+ // `centeredAtELO=${(tCentered - tActive).toFixed(0)}) ` +
96
+ // `[active=${activeCards.length} candidates=${newCards.length}]`
97
+ // );
90
98
 
91
99
  // Score new cards by ELO distance, then apply weighted sampling without
92
100
  // replacement using the Efraimidis-Spirakis (A-Res) algorithm:
@@ -99,8 +107,13 @@ export default class ELONavigator extends ContentNavigator implements CardGenera
99
107
  // cards from looping back every session when many cards share similar ELO.
100
108
  //
101
109
  // Edge case: rawScore=0 → key=0, never selected (correct exclusion).
102
- const scored: WeightedCard[] = newCards.map((c, i) => {
103
- const cardElo = cardEloData[i]?.global?.score ?? 1000;
110
+ //
111
+ // Card ELO is read from the pooled `.elo` carried on each candidate by
112
+ // getCardsCenteredAtELO — verified equal to a separate getCardEloData()
113
+ // fetch (0/500 mismatch on real data), so the redundant fetch is gone.
114
+ const scored: WeightedCard[] = newCards.map((c) => {
115
+ const cardElo = c.elo ?? 1000;
116
+
104
117
  const distance = Math.abs(cardElo - userGlobalElo);
105
118
  const rawScore = Math.max(0, 1 - distance / 500);
106
119
  const samplingKey = rawScore > 0 ? Math.random() ** (1 / rawScore) : 0;
@@ -126,8 +126,11 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
126
126
  throw new Error('SRSNavigator requires user and course to be set');
127
127
  }
128
128
 
129
+ // [perf] parked 2026-05 (pipeline-docs-workup) — uncomment to re-measure
130
+ // const tSrs0 = performance.now();
129
131
  const courseId = this.course.getCourseID();
130
132
  const reviews = await this.user.getPendingReviews(courseId);
133
+ // const tReviews = performance.now();
131
134
  const now = moment.utc();
132
135
 
133
136
  // Filter to only cards that are actually due
@@ -210,6 +213,13 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
210
213
  });
211
214
 
212
215
  // Sort by score descending and limit
216
+ // [perf] parked: SRSgen / getPendingReviews timing
217
+ // const srsResult = { cards: scored.sort((a, b) => b.score - a.score).slice(0, limit) };
218
+ // logger.info(
219
+ // `[perf][SRSgen] total=${(performance.now() - tSrs0).toFixed(0)}ms ` +
220
+ // `(pendingReviews=${(tReviews - tSrs0).toFixed(0)}) ` +
221
+ // `[scheduled=${reviews.length} due=${dueReviews.length}]`
222
+ // );
213
223
  return { cards: scored.sort((a, b) => b.score - a.score).slice(0, limit) };
214
224
  }
215
225