@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.
- package/dist/core/index.js +80 -21
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +80 -21
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.d.cts +32 -0
- package/dist/impl/couch/index.d.ts +32 -0
- package/dist/impl/couch/index.js +80 -21
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +80 -21
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.js +8 -5
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +8 -5
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +94 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +94 -22
- package/dist/index.mjs.map +1 -1
- package/docs/session-lifecycle-and-replan.md +418 -0
- package/package.json +3 -3
- package/src/core/UserDBDebugger.ts +7 -1
- package/src/core/navigators/Pipeline.ts +4 -0
- package/src/core/navigators/generators/elo.ts +19 -6
- package/src/core/navigators/generators/srs.ts +10 -0
- package/src/impl/couch/courseDB.ts +146 -17
- package/src/study/SessionController.ts +56 -1
- package/src/study/services/CardHydrationService.ts +24 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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)
|
|
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
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
|