@vue-skuilder/db 0.2.1 → 0.2.3

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.
Files changed (44) hide show
  1. package/dist/{contentSource-Ht3N2f-y.d.ts → contentSource-Cplhv3bJ.d.ts} +1 -1
  2. package/dist/{contentSource-BMlMwSiG.d.cts → contentSource-kI9_jwTu.d.cts} +1 -1
  3. package/dist/core/index.d.cts +5 -5
  4. package/dist/core/index.d.ts +5 -5
  5. package/dist/core/index.js +76 -21
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +76 -21
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BEqB8VBR.d.cts → dataLayerProvider-CiA2Rr0v.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-DObSXjnf.d.ts → dataLayerProvider-DrBqOUa3.d.ts} +1 -1
  11. package/dist/impl/couch/index.d.cts +35 -3
  12. package/dist/impl/couch/index.d.ts +35 -3
  13. package/dist/impl/couch/index.js +76 -21
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +76 -21
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +4 -4
  18. package/dist/impl/static/index.d.ts +4 -4
  19. package/dist/impl/static/index.js +4 -5
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +4 -5
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-BWvO-_rJ.d.ts → index-BLLT5BYE.d.ts} +1 -1
  24. package/dist/{index-Ba7hYbHj.d.cts → index-k9NFHpS1.d.cts} +1 -1
  25. package/dist/index.d.cts +164 -10
  26. package/dist/index.d.ts +164 -10
  27. package/dist/index.js +215 -28
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +215 -28
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/{types-W8n-B6HG.d.cts → types-BFUa1pa3.d.cts} +1 -1
  32. package/dist/{types-CJrLM1Ew.d.ts → types-CHgpWQAY.d.ts} +1 -1
  33. package/dist/{types-legacy-JXDxinpU.d.cts → types-legacy-4tlwHnXo.d.cts} +1 -1
  34. package/dist/{types-legacy-JXDxinpU.d.ts → types-legacy-4tlwHnXo.d.ts} +1 -1
  35. package/dist/util/packer/index.d.cts +3 -3
  36. package/dist/util/packer/index.d.ts +3 -3
  37. package/docs/session-lifecycle-and-replan.md +418 -0
  38. package/package.json +3 -3
  39. package/src/core/navigators/Pipeline.ts +5 -1
  40. package/src/core/navigators/generators/elo.ts +19 -6
  41. package/src/core/navigators/generators/srs.ts +10 -0
  42. package/src/impl/couch/courseDB.ts +146 -17
  43. package/src/study/SessionController.ts +295 -13
  44. package/src/study/services/CardHydrationService.ts +24 -0
@@ -302,11 +302,19 @@ export class CourseDB implements CourseDBInterface {
302
302
  elo = parseInt(elo as any);
303
303
  const limit = cardLimit ? cardLimit : 25;
304
304
 
305
+ // const tQ0 = performance.now();
306
+ // NOTE: `stale: 'update_after'` was tried here and removed — it gave no
307
+ // measurable speedup (PouchDB 9 effectively ignores it for the reindex
308
+ // cost) AND it can return an empty result on the first query after a cold
309
+ // DB open (index not yet loaded), which then poisons the pool cache. The
310
+ // session pool cache (see getCardsCenteredAtELO) is what removes the
311
+ // per-run cost, so we read the view normally (always-fresh) here.
305
312
  const below: PouchDB.Query.Response<object> = await this.db.query('elo', {
306
313
  limit: Math.ceil(limit / 2),
307
314
  startkey: elo,
308
315
  descending: true,
309
316
  });
317
+ // const tBelowQ = performance.now();
310
318
 
311
319
  const aboveLimit = limit - below.rows.length;
312
320
 
@@ -314,7 +322,13 @@ export class CourseDB implements CourseDBInterface {
314
322
  limit: aboveLimit,
315
323
  startkey: elo + 1,
316
324
  });
317
- // logger.log(JSON.stringify(below));
325
+ // const tAbove = performance.now();
326
+ // [perf] parked: getCardsByELO view-query timing (below/above split)
327
+ // logger.info(
328
+ // `[perf][getCardsByELO] reqLimit=${limit} ` +
329
+ // `below=${(tBelowQ - tQ0).toFixed(0)}ms(${below.rows.length}r) ` +
330
+ // `above=${(tAbove - tBelowQ).toFixed(0)}ms(${above.rows.length}r)`
331
+ // );
318
332
 
319
333
  let cards = below.rows;
320
334
  cards = cards.concat(above.rows);
@@ -573,6 +587,8 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
573
587
 
574
588
  async addNavigationStrategy(data: ContentNavigationStrategyData): Promise<void> {
575
589
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
590
+ // Strategy set changed — drop the cached navigator so it rebuilds.
591
+ this.invalidateNavigatorCache();
576
592
  // Admin write operation — use remote DB.
577
593
  return this.remoteDB.put(data).then(() => {});
578
594
  }
@@ -654,6 +670,35 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
654
670
  */
655
671
  private _pendingHints: ReplanHints | null = null;
656
672
 
673
+ /**
674
+ * Session-scoped cache of the broad ELO-neighbor pool used by
675
+ * getCardsCenteredAtELO. The `elo` view query re-indexes on first touch per
676
+ * call (PouchDB 9 ignores `stale`), so without this each plan/replan pays
677
+ * ~1.5-2s. The pool is fetched once and re-ranked against the live (roaming)
678
+ * ELO in memory on subsequent calls.
679
+ */
680
+ private _eloPoolCache: {
681
+ rows: (QualifiedCardID & { elo?: number })[];
682
+ fetchedAt: number;
683
+ } | null = null;
684
+ private readonly _eloPoolTtlMs = 5 * 60 * 1000;
685
+
686
+ /**
687
+ * Cached assembled navigator (Pipeline). createNavigator() reads strategy
688
+ * docs and builds a fresh Pipeline every call — whose internal `_tagCache`
689
+ * and `_cachedOrchestration` are designed to make replans cheap but never
690
+ * survive, because the instance is discarded each run. Caching it lets those
691
+ * caches persist across plan/replan within a session (SessionController holds
692
+ * one CourseDB instance for the session's lifetime). Rebuilt on user change,
693
+ * TTL expiry, or explicit invalidation after a strategy-doc write.
694
+ */
695
+ private _cachedNavigator: {
696
+ navigator: ContentNavigator;
697
+ userId: string;
698
+ builtAt: number;
699
+ } | null = null;
700
+ private readonly _navigatorTtlMs = 5 * 60 * 1000;
701
+
657
702
  public setEphemeralHints(hints: ReplanHints): void {
658
703
  this._pendingHints = hints;
659
704
  }
@@ -662,18 +707,60 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
662
707
  const u = await this._getCurrentUser();
663
708
 
664
709
  try {
665
- const navigator = await this.createNavigator(u);
710
+ // const tNav0 = performance.now(); // [perf] parked
711
+ const { navigator } = await this._getCachedNavigator(u);
712
+ // const tNav1 = performance.now(); // [perf] parked
666
713
  if (this._pendingHints) {
667
714
  navigator.setEphemeralHints(this._pendingHints);
668
715
  this._pendingHints = null;
669
716
  }
670
- return navigator.getWeightedCards(limit);
717
+ const result = await navigator.getWeightedCards(limit);
718
+ // const tRun = performance.now(); // [perf] parked
719
+ // [perf] parked 2026-05 (pipeline-docs-workup) — uncomment to re-measure
720
+ // logger.info(
721
+ // `[perf][courseDB] getWeightedCards(limit=${limit}): ` +
722
+ // `navigator=${(tNav1 - tNav0).toFixed(0)}ms(${navCache}) ` +
723
+ // `pipelineRun=${(tRun - tNav1).toFixed(0)}ms ` +
724
+ // `total=${(tRun - tNav0).toFixed(0)}ms`
725
+ // );
726
+ return result;
671
727
  } catch (e) {
672
728
  logger.error(`[courseDB] Error getting weighted cards: ${e}`);
673
729
  throw e;
674
730
  }
675
731
  }
676
732
 
733
+ /**
734
+ * Return the assembled navigator, reusing the cached instance when possible.
735
+ * Reuse preserves the Pipeline's per-session caches (tags, orchestration
736
+ * context) across replans, which is the dominant per-replan cost once the
737
+ * ELO-pool cost is removed. Rebuilds on user change or TTL expiry.
738
+ */
739
+ private async _getCachedNavigator(
740
+ user: UserDBInterface
741
+ ): Promise<{ navigator: ContentNavigator; cacheStatus: 'hit' | 'miss' }> {
742
+ const userId = user.getUsername();
743
+ const now = Date.now();
744
+ if (
745
+ this._cachedNavigator &&
746
+ this._cachedNavigator.userId === userId &&
747
+ now - this._cachedNavigator.builtAt < this._navigatorTtlMs
748
+ ) {
749
+ return { navigator: this._cachedNavigator.navigator, cacheStatus: 'hit' };
750
+ }
751
+ const navigator = await this.createNavigator(user);
752
+ this._cachedNavigator = { navigator, userId, builtAt: now };
753
+ return { navigator, cacheStatus: 'miss' };
754
+ }
755
+
756
+ /**
757
+ * Drop the cached navigator so the next getWeightedCards() rebuilds it.
758
+ * Call after mutating this course's navigation strategy documents.
759
+ */
760
+ public invalidateNavigatorCache(): void {
761
+ this._cachedNavigator = null;
762
+ }
763
+
677
764
  public async getCardsCenteredAtELO(
678
765
  options: {
679
766
  limit: number;
@@ -684,6 +771,9 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
684
771
  },
685
772
  filter?: (a: QualifiedCardID) => boolean
686
773
  ): Promise<StudySessionItem[]> {
774
+ // [perf] parked: getCardsCenteredAtELO rewrite banner
775
+ // logger.info('[perf][run] getCardsCenteredAtELO rewrite (session pool cache + in-memory recenter)');
776
+ // const tCelo0 = performance.now();
687
777
  let targetElo: number;
688
778
 
689
779
  if (options.elo === 'user') {
@@ -706,26 +796,65 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
706
796
  targetElo = options.elo;
707
797
  }
708
798
 
709
- let cards: (QualifiedCardID & { elo?: number })[] = [];
710
- let mult: number = 4;
711
- let previousCount: number = -1;
712
- let newCount: number = 0;
799
+ // const tReg = performance.now();
800
+
801
+ // Broad neighbor pool fetched once per session and re-used. We over-fetch
802
+ // (POOL_SIZE >> limit) so that the in-memory active-card filter and the
803
+ // slowly-roaming ELO both have ample headroom before a refetch is needed.
804
+ const POOL_SIZE = Math.max(2000, options.limit * 4);
805
+ const nowMs = Date.now();
806
+ let cacheStatus: 'hit' | 'miss' | 'refresh' = 'hit';
807
+
808
+ if (!this._eloPoolCache || nowMs - this._eloPoolCache.fetchedAt > this._eloPoolTtlMs) {
809
+ // MISS: pay the (reindexing) view query once, then cache the raw pool.
810
+ // Guard: never cache an EMPTY pool. A cold-DB-open or sync-race fetch can
811
+ // transiently return [], and caching it would starve the session for the
812
+ // whole TTL. Leaving the cache untouched lets the next call retry.
813
+ const fetched = await this.getCardsByELO(targetElo, POOL_SIZE);
814
+ if (fetched.length > 0) {
815
+ this._eloPoolCache = { rows: fetched, fetchedAt: nowMs };
816
+ }
817
+ cacheStatus = 'miss';
818
+ }
713
819
 
714
- while (cards.length < options.limit && newCount !== previousCount) {
715
- cards = await this.getCardsByELO(targetElo, mult * options.limit);
716
- previousCount = newCount;
717
- newCount = cards.length;
820
+ // Apply the (fresh) caller filter, then re-center against the *current* ELO.
821
+ // Returns a new array each call — the cached pool is never mutated, and the
822
+ // ranking reflects the live ELO even as it drifts within a session.
823
+ const rankAgainstCurrentElo = (): (QualifiedCardID & { elo?: number })[] => {
824
+ const raw = this._eloPoolCache?.rows ?? [];
825
+ const survivors = filter ? raw.filter((c) => filter(c)) : raw;
826
+ return survivors
827
+ .map((c) => ({ ...c }))
828
+ .sort(
829
+ (a, b) =>
830
+ Math.abs((a.elo ?? targetElo) - targetElo) -
831
+ Math.abs((b.elo ?? targetElo) - targetElo)
832
+ );
833
+ };
718
834
 
719
- logger.debug(`Found ${cards.length} elo neighbor cards...`);
835
+ let cards = rankAgainstCurrentElo();
720
836
 
721
- if (filter) {
722
- cards = cards.filter(filter);
723
- logger.debug(`Filtered to ${cards.length} cards...`);
837
+ // Refetch once if the pool can't satisfy the limit — either the active-card
838
+ // filter has grown past pool coverage (hit), or the pool is missing because
839
+ // a prior fetch came back empty (cold open / sync race). A miss that cached
840
+ // a non-empty-but-small pool (genuinely small course) is left alone.
841
+ if (cards.length < options.limit && (cacheStatus === 'hit' || !this._eloPoolCache)) {
842
+ const fetched = await this.getCardsByELO(targetElo, POOL_SIZE);
843
+ if (fetched.length > 0) {
844
+ this._eloPoolCache = { rows: fetched, fetchedAt: nowMs };
724
845
  }
725
-
726
- mult *= 2;
846
+ cards = rankAgainstCurrentElo();
847
+ cacheStatus = 'refresh';
727
848
  }
728
849
 
850
+ // [perf] parked: centeredAtELO regDoc / pool-cache timing
851
+ // logger.info(
852
+ // `[perf][centeredAtELO] regDoc=${(tReg - tCelo0).toFixed(0)}ms ` +
853
+ // `cache=${cacheStatus} build=${(performance.now() - tReg).toFixed(0)}ms ` +
854
+ // `poolRaw=${this._eloPoolCache?.rows.length ?? 0} postFilter=${cards.length} ` +
855
+ // `limit=${options.limit} targetElo=${targetElo}`
856
+ // );
857
+
729
858
  const selectedCards: {
730
859
  courseID: string;
731
860
  cardID: string;
@@ -12,11 +12,12 @@ import {
12
12
  StudySessionReviewItem,
13
13
  } from '@db/impl/couch';
14
14
 
15
- import { CardRecord, CardHistory, CourseRegistrationDoc, QuestionRecord } from '@db/core';
15
+ import { CardRecord, CardHistory, CourseRegistrationDoc, QuestionRecord, isQuestionRecord } from '@db/core';
16
16
  import { recordUserOutcome } from '@db/core/orchestration/recording';
17
17
  import { Loggable } from '@db/util';
18
18
  import { getCardOrigin } from '@db/core/navigators';
19
19
  import { ReplanHints } from '@db/core/navigators/generators/types';
20
+ import { mergeHints } from '@db/core/navigators/Pipeline';
20
21
  import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
21
22
  import { captureMixerRun } from './MixerDebugger';
22
23
  import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
@@ -35,6 +36,28 @@ export type { ReplanHints } from '@db/core/navigators/generators/types';
35
36
  export interface ReplanOptions {
36
37
  /** Scoring hints forwarded to the pipeline (boost/exclude/require). */
37
38
  hints?: ReplanHints;
39
+ /**
40
+ * Session-durable scoring hints. Unlike `hints` (one-shot, applied to
41
+ * exactly the run this replan triggers), `sessionHints` are stashed on
42
+ * the controller and re-merged into *every* subsequent pipeline run for
43
+ * the remainder of the session — including the bare auto-replans
44
+ * (depletion/quality) that carry no caller hints, and the wedge-breaker.
45
+ *
46
+ * Use for "emphasis that should outlive a single queue rebuild" — e.g.
47
+ * boosting a just-failed concept tag, or a post-lesson concept boost set
48
+ * at session start. Without this, a one-shot `hints` boost evaporates on
49
+ * the next replan and the freshly-rebuilt (replace-mode) queue clobbers
50
+ * whatever it surfaced.
51
+ *
52
+ * Semantics (KISS): setting `sessionHints` *replaces* the prior session
53
+ * hints wholesale (caller beware — no accumulation, no decay). They live
54
+ * until session end or until explicitly overwritten. Normal usage applies
55
+ * a fixed boost, so repeated identical requests are no-ops.
56
+ *
57
+ * Merged with per-run `hints` via the pipeline's `mergeHints` (boosts
58
+ * multiply, require/exclude lists concatenate).
59
+ */
60
+ sessionHints?: ReplanHints;
38
61
  /**
39
62
  * Maximum number of new cards to return from the pipeline.
40
63
  * Default: 20 (the standard session batch size).
@@ -102,6 +125,64 @@ export interface ResponseResult {
102
125
  shouldClearFeedbackShadow: boolean;
103
126
  }
104
127
 
128
+ /**
129
+ * Read-only snapshot of a single processed response, handed to every
130
+ * registered {@link OutcomeObserver} after ELO/SRS have been recorded.
131
+ *
132
+ * Only emitted for question records (non-question dismisses are skipped).
133
+ */
134
+ export interface SessionOutcome {
135
+ /** The user's response. Includes `isCorrect`, `performance`, `priorAttemps`. */
136
+ readonly record: QuestionRecord;
137
+ /**
138
+ * The card that was answered, including its `tags` — the primary key an
139
+ * observer matches against (e.g. `gpc:exercise:*`). `card_elo` reflects
140
+ * pre-update state; the ELO write for this response is already in flight.
141
+ */
142
+ readonly card: StudySessionRecord['card'];
143
+ /** The navigation decision produced for this response (read-only). */
144
+ readonly result: Readonly<ResponseResult>;
145
+ }
146
+
147
+ /**
148
+ * The narrow capability surface handed to an {@link OutcomeObserver}. This is
149
+ * the *only* way an observer can affect the session — it cannot touch ELO,
150
+ * the queues, the timer, or mutate the `ResponseResult`. A misbehaving
151
+ * observer degrades to "wrong boost", never "corrupted session".
152
+ */
153
+ export interface SessionControls {
154
+ /** Current session-durable hints, or null. For read-modify-write. */
155
+ getSessionHints(): ReplanHints | null;
156
+ /** Replace the session-durable hints wholesale (no decay). */
157
+ setSessionHints(hints: ReplanHints | null): void;
158
+ /**
159
+ * Merge `hints` into the existing session-durable hints via the pipeline's
160
+ * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
161
+ * Convenience for the common "add a boost on top of what's there" case.
162
+ * Note: multiplicative + no decay — clamp boost factors yourself if a
163
+ * repeatedly-failed tag could compound unboundedly.
164
+ */
165
+ mergeSessionHints(hints: ReplanHints): void;
166
+ /** Request a replan (e.g. `{ mode: 'merge' }` for immediate visibility). */
167
+ requestReplan(opts?: ReplanOptions): Promise<void>;
168
+ }
169
+
170
+ /**
171
+ * A consumer-supplied hook invoked after each question response is processed.
172
+ *
173
+ * Fires on *every* question response (gate inside on `record.isCorrect` /
174
+ * `result.nextCardAction` as needed). Awaited but isolated: a throwing
175
+ * observer is caught and logged, never wedging the session. Keep the
176
+ * synchronous body cheap and `void` any long work (e.g. a triggered replan)
177
+ * so you don't stall navigation.
178
+ *
179
+ * Registered via `StudySessionConfig.outcomeObservers` → constructor options.
180
+ */
181
+ export type OutcomeObserver = (
182
+ outcome: SessionOutcome,
183
+ controls: SessionControls
184
+ ) => void | Promise<void>;
185
+
105
186
  interface SessionServices {
106
187
  response: ResponseProcessor;
107
188
  }
@@ -177,6 +258,35 @@ export class SessionController<TView = unknown> extends Loggable {
177
258
  */
178
259
  private _minCardsGuarantee: number = 0;
179
260
 
261
+ /**
262
+ * Session-durable scoring hints. Re-merged into every pipeline run for
263
+ * the rest of the session (initial plan + every replan, including bare
264
+ * auto-replans and the wedge-breaker), via `_applyHintsToSources`.
265
+ *
266
+ * Set by `setSessionHints()` (e.g. session-start post-lesson boost) or by
267
+ * any replan carrying `ReplanOptions.sessionHints` (e.g. a just-failed
268
+ * concept boost). Replace semantics, no decay — lives until overwritten
269
+ * or session end. See `ReplanOptions.sessionHints` for rationale.
270
+ *
271
+ * Note: the controller-managed auto-excludes (current card, session
272
+ * record, imminent draw) are intentionally NOT folded in here — those are
273
+ * recomputed per-run in `_runReplan` and would otherwise go stale.
274
+ */
275
+ private _sessionHints: ReplanHints | null = null;
276
+
277
+ /**
278
+ * Consumer-supplied hooks invoked after each question response is processed.
279
+ * Seeded from constructor options (threaded from
280
+ * `StudySessionConfig.outcomeObservers`). See {@link OutcomeObserver}.
281
+ */
282
+ private _outcomeObservers: OutcomeObserver[] = [];
283
+
284
+ /**
285
+ * Lazily-built, stable capability object handed to observers. Bound to
286
+ * `this`; constructed once so observers can rely on referential identity.
287
+ */
288
+ private _sessionControls: SessionControls | null = null;
289
+
180
290
  private startTime: Date;
181
291
  private endTime: Date;
182
292
  private _secondsRemaining: number;
@@ -219,7 +329,11 @@ export class SessionController<TView = unknown> extends Loggable {
219
329
  dataLayer: DataLayerProvider,
220
330
  getViewComponent: (viewId: string) => TView,
221
331
  mixer?: SourceMixer,
222
- options?: { defaultBatchLimit?: number; initialReviewCap?: number }
332
+ options?: {
333
+ defaultBatchLimit?: number;
334
+ initialReviewCap?: number;
335
+ outcomeObservers?: OutcomeObserver[];
336
+ }
223
337
  ) {
224
338
  super();
225
339
 
@@ -249,6 +363,9 @@ export class SessionController<TView = unknown> extends Loggable {
249
363
  if (options?.initialReviewCap !== undefined) {
250
364
  this._initialReviewCap = options.initialReviewCap;
251
365
  }
366
+ if (options?.outcomeObservers?.length) {
367
+ this._outcomeObservers = [...options.outcomeObservers];
368
+ }
252
369
 
253
370
  this.log(`Session constructed:
254
371
  startTime: ${this.startTime}
@@ -409,6 +526,7 @@ export class SessionController<TView = unknown> extends Loggable {
409
526
  if (opts.minFollowUpCards !== undefined) return true;
410
527
  if (opts.mode && opts.mode !== 'replace') return true;
411
528
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
529
+ if (opts.sessionHints !== undefined) return true;
412
530
  return false;
413
531
  }
414
532
 
@@ -455,17 +573,21 @@ export class SessionController<TView = unknown> extends Loggable {
455
573
 
456
574
  hints.excludeCards = [...excludeSet];
457
575
 
458
- // Forward hints to all sources (CourseDB stashes them, Pipeline consumes them)
459
- if (opts.hints) {
460
- // Thread label into hints so Pipeline can attach it to provenance
461
- const hintsWithLabel = opts.label
462
- ? { ...opts.hints, _label: opts.label }
463
- : opts.hints;
464
- for (const source of this.sources) {
465
- source.setEphemeralHints?.(hintsWithLabel);
466
- }
576
+ // Replace session-durable hints if this replan carries them. KISS:
577
+ // wholesale replace, no accumulation/decay (see ReplanOptions.sessionHints).
578
+ if (opts.sessionHints !== undefined) {
579
+ this._sessionHints = opts.sessionHints;
580
+ this.log(
581
+ `[Replan] Session hints ${opts.sessionHints ? 'set' : 'cleared'}: ` +
582
+ `${JSON.stringify(opts.sessionHints)}`
583
+ );
467
584
  }
468
585
 
586
+ // Forward hints to all sources (CourseDB stashes them, Pipeline consumes
587
+ // them). The one-shot `opts.hints` are merged with the durable
588
+ // `_sessionHints` so session emphasis survives this and every later run.
589
+ this._applyHintsToSources(opts.hints, opts.label);
590
+
469
591
  const labelTag = opts.label ? ` [${opts.label}]` : '';
470
592
  this.log(
471
593
  `Mid-session replan requested${labelTag}` +
@@ -478,7 +600,124 @@ export class SessionController<TView = unknown> extends Loggable {
478
600
  this.log(`[Replan] Card guarantee set to ${this._minCardsGuarantee}`);
479
601
  }
480
602
 
603
+ // [perf] parked 2026-05 (pipeline-docs-workup) — uncomment to re-measure
604
+ // const tReplan0 = performance.now();
481
605
  await this._executeReplan(opts);
606
+ // logger.info(
607
+ // `[perf][SessionController] replan${labelTag} (limit=${opts.limit ?? 'default'}, ` +
608
+ // `mode=${opts.mode ?? 'replace'}) took ${(performance.now() - tReplan0).toFixed(0)}ms`
609
+ // );
610
+ }
611
+
612
+ /**
613
+ * Set the session-durable scoring hints (replace semantics, no decay).
614
+ *
615
+ * Unlike a one-shot replan hint, these are re-merged into every pipeline
616
+ * run for the rest of the session — including the initial plan when set
617
+ * before `prepareSession()`, every replan, the bare auto-replans, and the
618
+ * wedge-breaker. Pass `null` to clear.
619
+ *
620
+ * Typical callers:
621
+ * - `StudySession` at session start, threading `StudySessionConfig.initHints`
622
+ * (e.g. a post-lesson concept boost) — so the boost outlives the first
623
+ * queue rebuild instead of being clobbered by the first auto-replan.
624
+ * - A consumer view on a failure, boosting the just-failed concept tag.
625
+ *
626
+ * Does not itself trigger a replan; the next plan/replan picks it up.
627
+ */
628
+ public setSessionHints(hints: ReplanHints | null): void {
629
+ this._sessionHints = hints;
630
+ this.log(`Session hints ${hints ? 'set' : 'cleared'}: ${JSON.stringify(hints)}`);
631
+ }
632
+
633
+ /**
634
+ * Read the current session-durable hints (for read-modify-write callers,
635
+ * e.g. an outcome observer that clamps a compounding boost).
636
+ */
637
+ public getSessionHints(): ReplanHints | null {
638
+ return this._sessionHints;
639
+ }
640
+
641
+ /**
642
+ * Merge `hints` into the durable session hints via the pipeline's
643
+ * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
644
+ * Convenience over get-then-set for the common additive case. Note the
645
+ * multiplicative, no-decay semantics — clamp boost factors at the call
646
+ * site if a repeatedly-emphasised tag could compound unboundedly.
647
+ */
648
+ public mergeSessionHints(hints: ReplanHints): void {
649
+ this._sessionHints = mergeHints([this._sessionHints, hints]) ?? null;
650
+ this.log(`Session hints merged: ${JSON.stringify(this._sessionHints)}`);
651
+ }
652
+
653
+ /**
654
+ * Merge the durable `_sessionHints` with this run's one-shot hints and
655
+ * push the result to every source for consumption on the next pipeline
656
+ * run. Centralised so the initial plan and all replan paths apply session
657
+ * emphasis identically. No-op when there are no hints of either kind.
658
+ */
659
+ private _applyHintsToSources(oneShot?: ReplanHints, label?: string): void {
660
+ // Thread the provenance label into the one-shot layer; mergeHints will
661
+ // fold it into the combined `_label`.
662
+ const oneShotWithLabel: ReplanHints | undefined =
663
+ oneShot && label ? { ...oneShot, _label: label } : oneShot;
664
+
665
+ const merged = mergeHints([this._sessionHints, oneShotWithLabel]);
666
+ if (!merged) return;
667
+
668
+ for (const source of this.sources) {
669
+ source.setEphemeralHints?.(merged);
670
+ }
671
+ }
672
+
673
+ /**
674
+ * Build (once) the stable capability object handed to outcome observers.
675
+ * Methods are bound to `this`; the object identity is stable across calls
676
+ * so observers may key off it.
677
+ */
678
+ private _getSessionControls(): SessionControls {
679
+ if (!this._sessionControls) {
680
+ this._sessionControls = {
681
+ getSessionHints: () => this.getSessionHints(),
682
+ setSessionHints: (h) => this.setSessionHints(h),
683
+ mergeSessionHints: (h) => this.mergeSessionHints(h),
684
+ requestReplan: (opts) => this.requestReplan(opts),
685
+ };
686
+ }
687
+ return this._sessionControls;
688
+ }
689
+
690
+ /**
691
+ * Notify registered outcome observers about a processed response.
692
+ *
693
+ * Only question records are surfaced (non-question dismisses are skipped).
694
+ * Observers run after ELO/SRS are recorded and before navigation. Each is
695
+ * awaited but isolated in try/catch — a throwing observer is logged and
696
+ * skipped, never wedging the session. Keep observers cheap and `void` any
697
+ * long work (e.g. a triggered replan) to avoid stalling the draw.
698
+ */
699
+ private async _notifyOutcomeObservers(
700
+ record: CardRecord,
701
+ currentCard: StudySessionRecord,
702
+ result: ResponseResult
703
+ ): Promise<void> {
704
+ if (this._outcomeObservers.length === 0) return;
705
+ if (!isQuestionRecord(record)) return;
706
+
707
+ const outcome: SessionOutcome = {
708
+ record,
709
+ card: currentCard.card,
710
+ result,
711
+ };
712
+ const controls = this._getSessionControls();
713
+
714
+ for (const observer of this._outcomeObservers) {
715
+ try {
716
+ await observer(outcome, controls);
717
+ } catch (e) {
718
+ this.error('[OutcomeObserver] observer threw; ignoring', e);
719
+ }
720
+ }
482
721
  }
483
722
 
484
723
  /**
@@ -513,7 +752,7 @@ export class SessionController<TView = unknown> extends Loggable {
513
752
  if (!input) return {};
514
753
 
515
754
  // If the input has any ReplanOptions-specific key, treat it as ReplanOptions
516
- const replanKeys = ['hints', 'limit', 'mode', 'label', 'minFollowUpCards'];
755
+ const replanKeys = ['hints', 'sessionHints', 'limit', 'mode', 'label', 'minFollowUpCards'];
517
756
  const inputKeys = Object.keys(input);
518
757
  if (inputKeys.some((k) => replanKeys.includes(k))) {
519
758
  return input as ReplanOptions;
@@ -691,6 +930,7 @@ export class SessionController<TView = unknown> extends Loggable {
691
930
  additive?: boolean;
692
931
  limit?: number;
693
932
  }): Promise<number> {
933
+ // const tGwc0 = performance.now(); // [perf] parked
694
934
  const replan = options?.replan ?? false;
695
935
  const additive = options?.additive ?? false;
696
936
  const newLimit = options?.limit ?? this._defaultBatchLimit;
@@ -699,6 +939,14 @@ export class SessionController<TView = unknown> extends Loggable {
699
939
  // never touch reviewQ, so the inflation is unnecessary there.
700
940
  const fetchLimit = replan ? newLimit : newLimit + this._initialReviewCap;
701
941
 
942
+ // Initial plan: push session-durable hints to sources so the very first
943
+ // pipeline run reflects them (e.g. a post-lesson boost). Replans push
944
+ // their own session+one-shot merge via _runReplan before reaching here,
945
+ // so we must NOT re-apply here or we'd drop their per-run excludeCards.
946
+ if (!replan) {
947
+ this._applyHintsToSources();
948
+ }
949
+
702
950
  // Collect batches from each source
703
951
  const batches: SourceBatch[] = [];
704
952
 
@@ -721,6 +969,8 @@ export class SessionController<TView = unknown> extends Loggable {
721
969
  }
722
970
  }
723
971
 
972
+ // const tSources = performance.now(); // [perf] parked
973
+
724
974
  // Verify we got content from at least one source
725
975
  if (batches.length === 0) {
726
976
  if (replan) {
@@ -736,6 +986,7 @@ export class SessionController<TView = unknown> extends Loggable {
736
986
 
737
987
  // Mix weighted cards across sources using configured strategy
738
988
  const mixedWeighted = this.mixer.mix(batches, fetchLimit * this.sources.length);
989
+ // const tMixed = performance.now(); // [perf] parked
739
990
 
740
991
  // Capture mixer run for debugging - fetch course names
741
992
  const sourceIds = batches.map((b) => {
@@ -834,6 +1085,18 @@ export class SessionController<TView = unknown> extends Loggable {
834
1085
  }
835
1086
 
836
1087
  this.log(report);
1088
+
1089
+ // [perf] parked: getWeightedContent stage timing
1090
+ // const tEnd = performance.now();
1091
+ // logger.info(
1092
+ // `[perf][SessionController] getWeightedContent(replan=${replan}): ` +
1093
+ // `sources=${(tSources - tGwc0).toFixed(0)}ms ` +
1094
+ // `mix=${(tMixed - tSources).toFixed(0)}ms ` +
1095
+ // `post=${(tEnd - tMixed).toFixed(0)}ms ` +
1096
+ // `total=${(tEnd - tGwc0).toFixed(0)}ms ` +
1097
+ // `[sources=${this.sources.length} fetchLimit=${fetchLimit} newLimit=${newLimit}]`
1098
+ // );
1099
+
837
1100
  return wellIndicated;
838
1101
  }
839
1102
 
@@ -938,6 +1201,10 @@ export class SessionController<TView = unknown> extends Loggable {
938
1201
  public async nextCard(
939
1202
  action: SessionAction = 'dismiss-success'
940
1203
  ): Promise<HydratedCard<TView> | null> {
1204
+ // [perf] parked: nextCard provenance/timing (awaitedReplan, wedgeRuns)
1205
+ // const tNext0 = performance.now();
1206
+ // let awaitedInFlightReplan = false;
1207
+ // let wedgeRuns = 0;
941
1208
  // dismiss (or sort to failedQ) the current card
942
1209
  this.dismissCurrentCard(action);
943
1210
 
@@ -959,6 +1226,7 @@ export class SessionController<TView = unknown> extends Loggable {
959
1226
  this.failedQ.length === 0
960
1227
  ) {
961
1228
  this.log('nextCard: queues empty, awaiting in-flight replan before drawing');
1229
+ // awaitedInFlightReplan = true; // [perf] parked
962
1230
  await this._replanPromise;
963
1231
  }
964
1232
 
@@ -1053,6 +1321,7 @@ export class SessionController<TView = unknown> extends Loggable {
1053
1321
  `Running pipeline (attempt ${wedgeEmptyStreak + 1}/${WEDGE_MAX_EMPTY_STREAK}).`
1054
1322
  );
1055
1323
  await this._replanUncoalesced({ label: 'wedge-breaker' });
1324
+ // wedgeRuns++; // [perf] parked
1056
1325
  if (
1057
1326
  this.newQ.length === 0 &&
1058
1327
  this.reviewQ.length === 0 &&
@@ -1115,6 +1384,11 @@ export class SessionController<TView = unknown> extends Loggable {
1115
1384
  // Snapshot queue state
1116
1385
  snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
1117
1386
 
1387
+ // [perf] parked: per-draw nextCard timing
1388
+ // logger.info(
1389
+ // `[perf][nextCard] -> ${card.item.cardID} in ${(performance.now() - tNext0).toFixed(0)}ms ` +
1390
+ // `(awaitedReplan=${awaitedInFlightReplan} wedgeRuns=${wedgeRuns})`
1391
+ // );
1118
1392
  return card;
1119
1393
  }
1120
1394
 
@@ -1159,7 +1433,7 @@ export class SessionController<TView = unknown> extends Loggable {
1159
1433
  ...currentCard.item,
1160
1434
  };
1161
1435
 
1162
- return await this.services.response.processResponse(
1436
+ const result = await this.services.response.processResponse(
1163
1437
  cardRecord,
1164
1438
  cardHistory,
1165
1439
  studySessionItem,
@@ -1171,6 +1445,14 @@ export class SessionController<TView = unknown> extends Loggable {
1171
1445
  maxSessionViews,
1172
1446
  sessionViews
1173
1447
  );
1448
+
1449
+ // Surface the processed outcome to any registered observers (e.g. a
1450
+ // difficulty-booster that bumps session hints on a failed exercise tag).
1451
+ // Runs after ELO/SRS recording, before the caller navigates. Isolated so
1452
+ // a faulty observer can't break response handling.
1453
+ await this._notifyOutcomeObservers(cardRecord, currentCard, result);
1454
+
1455
+ return result;
1174
1456
  }
1175
1457
 
1176
1458
  private dismissCurrentCard(action: SessionAction = 'dismiss-success') {