@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
package/dist/index.mjs CHANGED
@@ -1959,10 +1959,8 @@ var init_elo = __esm({
1959
1959
  { limit, elo: "user" },
1960
1960
  (c) => !activeCards.some((ac) => c.cardID === ac.cardID)
1961
1961
  )).map((c) => ({ ...c, status: "new" }));
1962
- const cardIds = newCards.map((c) => c.cardID);
1963
- const cardEloData = await this.course.getCardEloData(cardIds);
1964
- const scored = newCards.map((c, i) => {
1965
- const cardElo = cardEloData[i]?.global?.score ?? 1e3;
1962
+ const scored = newCards.map((c) => {
1963
+ const cardElo = c.elo ?? 1e3;
1966
1964
  const distance = Math.abs(cardElo - userGlobalElo);
1967
1965
  const rawScore = Math.max(0, 1 - distance / 500);
1968
1966
  const samplingKey = rawScore > 0 ? Math.random() ** (1 / rawScore) : 0;
@@ -4186,7 +4184,8 @@ var init_orchestration = __esm({
4186
4184
  // src/core/navigators/Pipeline.ts
4187
4185
  var Pipeline_exports = {};
4188
4186
  __export(Pipeline_exports, {
4189
- Pipeline: () => Pipeline
4187
+ Pipeline: () => Pipeline,
4188
+ mergeHints: () => mergeHints2
4190
4189
  });
4191
4190
  import { toCourseElo as toCourseElo5 } from "@vue-skuilder/common";
4192
4191
  function globToRegex(pattern) {
@@ -5883,6 +5882,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
5883
5882
  }
5884
5883
  async addNavigationStrategy(data) {
5885
5884
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
5885
+ this.invalidateNavigatorCache();
5886
5886
  return this.remoteDB.put(data).then(() => {
5887
5887
  });
5888
5888
  }
@@ -5949,23 +5949,67 @@ ${e.stack}` : JSON.stringify(e);
5949
5949
  * @returns Cards sorted by score descending
5950
5950
  */
5951
5951
  _pendingHints = null;
5952
+ /**
5953
+ * Session-scoped cache of the broad ELO-neighbor pool used by
5954
+ * getCardsCenteredAtELO. The `elo` view query re-indexes on first touch per
5955
+ * call (PouchDB 9 ignores `stale`), so without this each plan/replan pays
5956
+ * ~1.5-2s. The pool is fetched once and re-ranked against the live (roaming)
5957
+ * ELO in memory on subsequent calls.
5958
+ */
5959
+ _eloPoolCache = null;
5960
+ _eloPoolTtlMs = 5 * 60 * 1e3;
5961
+ /**
5962
+ * Cached assembled navigator (Pipeline). createNavigator() reads strategy
5963
+ * docs and builds a fresh Pipeline every call — whose internal `_tagCache`
5964
+ * and `_cachedOrchestration` are designed to make replans cheap but never
5965
+ * survive, because the instance is discarded each run. Caching it lets those
5966
+ * caches persist across plan/replan within a session (SessionController holds
5967
+ * one CourseDB instance for the session's lifetime). Rebuilt on user change,
5968
+ * TTL expiry, or explicit invalidation after a strategy-doc write.
5969
+ */
5970
+ _cachedNavigator = null;
5971
+ _navigatorTtlMs = 5 * 60 * 1e3;
5952
5972
  setEphemeralHints(hints) {
5953
5973
  this._pendingHints = hints;
5954
5974
  }
5955
5975
  async getWeightedCards(limit) {
5956
5976
  const u = await this._getCurrentUser();
5957
5977
  try {
5958
- const navigator2 = await this.createNavigator(u);
5978
+ const { navigator: navigator2 } = await this._getCachedNavigator(u);
5959
5979
  if (this._pendingHints) {
5960
5980
  navigator2.setEphemeralHints(this._pendingHints);
5961
5981
  this._pendingHints = null;
5962
5982
  }
5963
- return navigator2.getWeightedCards(limit);
5983
+ const result = await navigator2.getWeightedCards(limit);
5984
+ return result;
5964
5985
  } catch (e) {
5965
5986
  logger.error(`[courseDB] Error getting weighted cards: ${e}`);
5966
5987
  throw e;
5967
5988
  }
5968
5989
  }
5990
+ /**
5991
+ * Return the assembled navigator, reusing the cached instance when possible.
5992
+ * Reuse preserves the Pipeline's per-session caches (tags, orchestration
5993
+ * context) across replans, which is the dominant per-replan cost once the
5994
+ * ELO-pool cost is removed. Rebuilds on user change or TTL expiry.
5995
+ */
5996
+ async _getCachedNavigator(user) {
5997
+ const userId = user.getUsername();
5998
+ const now = Date.now();
5999
+ if (this._cachedNavigator && this._cachedNavigator.userId === userId && now - this._cachedNavigator.builtAt < this._navigatorTtlMs) {
6000
+ return { navigator: this._cachedNavigator.navigator, cacheStatus: "hit" };
6001
+ }
6002
+ const navigator2 = await this.createNavigator(user);
6003
+ this._cachedNavigator = { navigator: navigator2, userId, builtAt: now };
6004
+ return { navigator: navigator2, cacheStatus: "miss" };
6005
+ }
6006
+ /**
6007
+ * Drop the cached navigator so the next getWeightedCards() rebuilds it.
6008
+ * Call after mutating this course's navigation strategy documents.
6009
+ */
6010
+ invalidateNavigatorCache() {
6011
+ this._cachedNavigator = null;
6012
+ }
5969
6013
  async getCardsCenteredAtELO(options = {
5970
6014
  limit: 99,
5971
6015
  elo: "user"
@@ -5988,20 +6032,31 @@ ${e.stack}` : JSON.stringify(e);
5988
6032
  } else {
5989
6033
  targetElo = options.elo;
5990
6034
  }
5991
- let cards = [];
5992
- let mult = 4;
5993
- let previousCount = -1;
5994
- let newCount = 0;
5995
- while (cards.length < options.limit && newCount !== previousCount) {
5996
- cards = await this.getCardsByELO(targetElo, mult * options.limit);
5997
- previousCount = newCount;
5998
- newCount = cards.length;
5999
- logger.debug(`Found ${cards.length} elo neighbor cards...`);
6000
- if (filter) {
6001
- cards = cards.filter(filter);
6002
- logger.debug(`Filtered to ${cards.length} cards...`);
6003
- }
6004
- mult *= 2;
6035
+ const POOL_SIZE = Math.max(2e3, options.limit * 4);
6036
+ const nowMs = Date.now();
6037
+ let cacheStatus = "hit";
6038
+ if (!this._eloPoolCache || nowMs - this._eloPoolCache.fetchedAt > this._eloPoolTtlMs) {
6039
+ const fetched = await this.getCardsByELO(targetElo, POOL_SIZE);
6040
+ if (fetched.length > 0) {
6041
+ this._eloPoolCache = { rows: fetched, fetchedAt: nowMs };
6042
+ }
6043
+ cacheStatus = "miss";
6044
+ }
6045
+ const rankAgainstCurrentElo = () => {
6046
+ const raw = this._eloPoolCache?.rows ?? [];
6047
+ const survivors = filter ? raw.filter((c) => filter(c)) : raw;
6048
+ return survivors.map((c) => ({ ...c })).sort(
6049
+ (a, b) => Math.abs((a.elo ?? targetElo) - targetElo) - Math.abs((b.elo ?? targetElo) - targetElo)
6050
+ );
6051
+ };
6052
+ let cards = rankAgainstCurrentElo();
6053
+ if (cards.length < options.limit && (cacheStatus === "hit" || !this._eloPoolCache)) {
6054
+ const fetched = await this.getCardsByELO(targetElo, POOL_SIZE);
6055
+ if (fetched.length > 0) {
6056
+ this._eloPoolCache = { rows: fetched, fetchedAt: nowMs };
6057
+ }
6058
+ cards = rankAgainstCurrentElo();
6059
+ cacheStatus = "refresh";
6005
6060
  }
6006
6061
  const selectedCards = [];
6007
6062
  while (selectedCards.length < options.limit && cards.length > 0) {
@@ -11188,6 +11243,7 @@ var ItemQueue = class {
11188
11243
 
11189
11244
  // src/study/SessionController.ts
11190
11245
  init_couch();
11246
+ init_core();
11191
11247
  init_recording();
11192
11248
 
11193
11249
  // src/util/index.ts
@@ -12614,6 +12670,7 @@ init_dataDirectory();
12614
12670
 
12615
12671
  // src/study/SessionController.ts
12616
12672
  init_navigators();
12673
+ init_Pipeline();
12617
12674
 
12618
12675
  // src/study/SourceMixer.ts
12619
12676
  var QuotaRoundRobinMixer = class {
@@ -13325,6 +13382,32 @@ var SessionController = class _SessionController extends Loggable {
13325
13382
  * each nextCard() draw. Set by replans that include `minFollowUpCards`.
13326
13383
  */
13327
13384
  _minCardsGuarantee = 0;
13385
+ /**
13386
+ * Session-durable scoring hints. Re-merged into every pipeline run for
13387
+ * the rest of the session (initial plan + every replan, including bare
13388
+ * auto-replans and the wedge-breaker), via `_applyHintsToSources`.
13389
+ *
13390
+ * Set by `setSessionHints()` (e.g. session-start post-lesson boost) or by
13391
+ * any replan carrying `ReplanOptions.sessionHints` (e.g. a just-failed
13392
+ * concept boost). Replace semantics, no decay — lives until overwritten
13393
+ * or session end. See `ReplanOptions.sessionHints` for rationale.
13394
+ *
13395
+ * Note: the controller-managed auto-excludes (current card, session
13396
+ * record, imminent draw) are intentionally NOT folded in here — those are
13397
+ * recomputed per-run in `_runReplan` and would otherwise go stale.
13398
+ */
13399
+ _sessionHints = null;
13400
+ /**
13401
+ * Consumer-supplied hooks invoked after each question response is processed.
13402
+ * Seeded from constructor options (threaded from
13403
+ * `StudySessionConfig.outcomeObservers`). See {@link OutcomeObserver}.
13404
+ */
13405
+ _outcomeObservers = [];
13406
+ /**
13407
+ * Lazily-built, stable capability object handed to observers. Bound to
13408
+ * `this`; constructed once so observers can rely on referential identity.
13409
+ */
13410
+ _sessionControls = null;
13328
13411
  startTime;
13329
13412
  endTime;
13330
13413
  _secondsRemaining;
@@ -13384,6 +13467,9 @@ var SessionController = class _SessionController extends Loggable {
13384
13467
  if (options?.initialReviewCap !== void 0) {
13385
13468
  this._initialReviewCap = options.initialReviewCap;
13386
13469
  }
13470
+ if (options?.outcomeObservers?.length) {
13471
+ this._outcomeObservers = [...options.outcomeObservers];
13472
+ }
13387
13473
  this.log(`Session constructed:
13388
13474
  startTime: ${this.startTime}
13389
13475
  endTime: ${this.endTime}
@@ -13511,6 +13597,7 @@ var SessionController = class _SessionController extends Loggable {
13511
13597
  if (opts.minFollowUpCards !== void 0) return true;
13512
13598
  if (opts.mode && opts.mode !== "replace") return true;
13513
13599
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
13600
+ if (opts.sessionHints !== void 0) return true;
13514
13601
  return false;
13515
13602
  }
13516
13603
  /**
@@ -13539,12 +13626,13 @@ var SessionController = class _SessionController extends Loggable {
13539
13626
  excludeSet.add(this.newQ.peek(0).cardID);
13540
13627
  }
13541
13628
  hints.excludeCards = [...excludeSet];
13542
- if (opts.hints) {
13543
- const hintsWithLabel = opts.label ? { ...opts.hints, _label: opts.label } : opts.hints;
13544
- for (const source of this.sources) {
13545
- source.setEphemeralHints?.(hintsWithLabel);
13546
- }
13629
+ if (opts.sessionHints !== void 0) {
13630
+ this._sessionHints = opts.sessionHints;
13631
+ this.log(
13632
+ `[Replan] Session hints ${opts.sessionHints ? "set" : "cleared"}: ${JSON.stringify(opts.sessionHints)}`
13633
+ );
13547
13634
  }
13635
+ this._applyHintsToSources(opts.hints, opts.label);
13548
13636
  const labelTag = opts.label ? ` [${opts.label}]` : "";
13549
13637
  this.log(
13550
13638
  `Mid-session replan requested${labelTag} (limit: ${opts.limit ?? "default"}, mode: ${opts.mode ?? "replace"}${opts.hints ? ", with hints" : ""})`
@@ -13555,6 +13643,100 @@ var SessionController = class _SessionController extends Loggable {
13555
13643
  }
13556
13644
  await this._executeReplan(opts);
13557
13645
  }
13646
+ /**
13647
+ * Set the session-durable scoring hints (replace semantics, no decay).
13648
+ *
13649
+ * Unlike a one-shot replan hint, these are re-merged into every pipeline
13650
+ * run for the rest of the session — including the initial plan when set
13651
+ * before `prepareSession()`, every replan, the bare auto-replans, and the
13652
+ * wedge-breaker. Pass `null` to clear.
13653
+ *
13654
+ * Typical callers:
13655
+ * - `StudySession` at session start, threading `StudySessionConfig.initHints`
13656
+ * (e.g. a post-lesson concept boost) — so the boost outlives the first
13657
+ * queue rebuild instead of being clobbered by the first auto-replan.
13658
+ * - A consumer view on a failure, boosting the just-failed concept tag.
13659
+ *
13660
+ * Does not itself trigger a replan; the next plan/replan picks it up.
13661
+ */
13662
+ setSessionHints(hints) {
13663
+ this._sessionHints = hints;
13664
+ this.log(`Session hints ${hints ? "set" : "cleared"}: ${JSON.stringify(hints)}`);
13665
+ }
13666
+ /**
13667
+ * Read the current session-durable hints (for read-modify-write callers,
13668
+ * e.g. an outcome observer that clamps a compounding boost).
13669
+ */
13670
+ getSessionHints() {
13671
+ return this._sessionHints;
13672
+ }
13673
+ /**
13674
+ * Merge `hints` into the durable session hints via the pipeline's
13675
+ * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
13676
+ * Convenience over get-then-set for the common additive case. Note the
13677
+ * multiplicative, no-decay semantics — clamp boost factors at the call
13678
+ * site if a repeatedly-emphasised tag could compound unboundedly.
13679
+ */
13680
+ mergeSessionHints(hints) {
13681
+ this._sessionHints = mergeHints2([this._sessionHints, hints]) ?? null;
13682
+ this.log(`Session hints merged: ${JSON.stringify(this._sessionHints)}`);
13683
+ }
13684
+ /**
13685
+ * Merge the durable `_sessionHints` with this run's one-shot hints and
13686
+ * push the result to every source for consumption on the next pipeline
13687
+ * run. Centralised so the initial plan and all replan paths apply session
13688
+ * emphasis identically. No-op when there are no hints of either kind.
13689
+ */
13690
+ _applyHintsToSources(oneShot, label) {
13691
+ const oneShotWithLabel = oneShot && label ? { ...oneShot, _label: label } : oneShot;
13692
+ const merged = mergeHints2([this._sessionHints, oneShotWithLabel]);
13693
+ if (!merged) return;
13694
+ for (const source of this.sources) {
13695
+ source.setEphemeralHints?.(merged);
13696
+ }
13697
+ }
13698
+ /**
13699
+ * Build (once) the stable capability object handed to outcome observers.
13700
+ * Methods are bound to `this`; the object identity is stable across calls
13701
+ * so observers may key off it.
13702
+ */
13703
+ _getSessionControls() {
13704
+ if (!this._sessionControls) {
13705
+ this._sessionControls = {
13706
+ getSessionHints: () => this.getSessionHints(),
13707
+ setSessionHints: (h) => this.setSessionHints(h),
13708
+ mergeSessionHints: (h) => this.mergeSessionHints(h),
13709
+ requestReplan: (opts) => this.requestReplan(opts)
13710
+ };
13711
+ }
13712
+ return this._sessionControls;
13713
+ }
13714
+ /**
13715
+ * Notify registered outcome observers about a processed response.
13716
+ *
13717
+ * Only question records are surfaced (non-question dismisses are skipped).
13718
+ * Observers run after ELO/SRS are recorded and before navigation. Each is
13719
+ * awaited but isolated in try/catch — a throwing observer is logged and
13720
+ * skipped, never wedging the session. Keep observers cheap and `void` any
13721
+ * long work (e.g. a triggered replan) to avoid stalling the draw.
13722
+ */
13723
+ async _notifyOutcomeObservers(record, currentCard, result) {
13724
+ if (this._outcomeObservers.length === 0) return;
13725
+ if (!isQuestionRecord(record)) return;
13726
+ const outcome = {
13727
+ record,
13728
+ card: currentCard.card,
13729
+ result
13730
+ };
13731
+ const controls = this._getSessionControls();
13732
+ for (const observer of this._outcomeObservers) {
13733
+ try {
13734
+ await observer(outcome, controls);
13735
+ } catch (e) {
13736
+ this.error("[OutcomeObserver] observer threw; ignoring", e);
13737
+ }
13738
+ }
13739
+ }
13558
13740
  /**
13559
13741
  * Run a replan, bypassing requestReplan()'s coalesce logic.
13560
13742
  *
@@ -13582,7 +13764,7 @@ var SessionController = class _SessionController extends Loggable {
13582
13764
  */
13583
13765
  normalizeReplanOptions(input) {
13584
13766
  if (!input) return {};
13585
- const replanKeys = ["hints", "limit", "mode", "label", "minFollowUpCards"];
13767
+ const replanKeys = ["hints", "sessionHints", "limit", "mode", "label", "minFollowUpCards"];
13586
13768
  const inputKeys = Object.keys(input);
13587
13769
  if (inputKeys.some((k) => replanKeys.includes(k))) {
13588
13770
  return input;
@@ -13734,6 +13916,9 @@ var SessionController = class _SessionController extends Loggable {
13734
13916
  const additive = options?.additive ?? false;
13735
13917
  const newLimit = options?.limit ?? this._defaultBatchLimit;
13736
13918
  const fetchLimit = replan ? newLimit : newLimit + this._initialReviewCap;
13919
+ if (!replan) {
13920
+ this._applyHintsToSources();
13921
+ }
13737
13922
  const batches = [];
13738
13923
  for (let i = 0; i < this.sources.length; i++) {
13739
13924
  const source = this.sources[i];
@@ -14014,7 +14199,7 @@ var SessionController = class _SessionController extends Loggable {
14014
14199
  const studySessionItem = {
14015
14200
  ...currentCard.item
14016
14201
  };
14017
- return await this.services.response.processResponse(
14202
+ const result = await this.services.response.processResponse(
14018
14203
  cardRecord,
14019
14204
  cardHistory,
14020
14205
  studySessionItem,
@@ -14026,6 +14211,8 @@ var SessionController = class _SessionController extends Loggable {
14026
14211
  maxSessionViews,
14027
14212
  sessionViews
14028
14213
  );
14214
+ await this._notifyOutcomeObservers(cardRecord, currentCard, result);
14215
+ return result;
14029
14216
  }
14030
14217
  dismissCurrentCard(action = "dismiss-success") {
14031
14218
  if (this._currentCard) {