@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.js CHANGED
@@ -1982,10 +1982,8 @@ var init_elo = __esm({
1982
1982
  { limit, elo: "user" },
1983
1983
  (c) => !activeCards.some((ac) => c.cardID === ac.cardID)
1984
1984
  )).map((c) => ({ ...c, status: "new" }));
1985
- const cardIds = newCards.map((c) => c.cardID);
1986
- const cardEloData = await this.course.getCardEloData(cardIds);
1987
- const scored = newCards.map((c, i) => {
1988
- const cardElo = cardEloData[i]?.global?.score ?? 1e3;
1985
+ const scored = newCards.map((c) => {
1986
+ const cardElo = c.elo ?? 1e3;
1989
1987
  const distance = Math.abs(cardElo - userGlobalElo);
1990
1988
  const rawScore = Math.max(0, 1 - distance / 500);
1991
1989
  const samplingKey = rawScore > 0 ? Math.random() ** (1 / rawScore) : 0;
@@ -4209,7 +4207,8 @@ var init_orchestration = __esm({
4209
4207
  // src/core/navigators/Pipeline.ts
4210
4208
  var Pipeline_exports = {};
4211
4209
  __export(Pipeline_exports, {
4212
- Pipeline: () => Pipeline
4210
+ Pipeline: () => Pipeline,
4211
+ mergeHints: () => mergeHints2
4213
4212
  });
4214
4213
  function globToRegex(pattern) {
4215
4214
  const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
@@ -5901,6 +5900,7 @@ ${above.rows.map((r) => ` ${r.id}-${r.key}
5901
5900
  }
5902
5901
  async addNavigationStrategy(data) {
5903
5902
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
5903
+ this.invalidateNavigatorCache();
5904
5904
  return this.remoteDB.put(data).then(() => {
5905
5905
  });
5906
5906
  }
@@ -5967,23 +5967,67 @@ ${e.stack}` : JSON.stringify(e);
5967
5967
  * @returns Cards sorted by score descending
5968
5968
  */
5969
5969
  _pendingHints = null;
5970
+ /**
5971
+ * Session-scoped cache of the broad ELO-neighbor pool used by
5972
+ * getCardsCenteredAtELO. The `elo` view query re-indexes on first touch per
5973
+ * call (PouchDB 9 ignores `stale`), so without this each plan/replan pays
5974
+ * ~1.5-2s. The pool is fetched once and re-ranked against the live (roaming)
5975
+ * ELO in memory on subsequent calls.
5976
+ */
5977
+ _eloPoolCache = null;
5978
+ _eloPoolTtlMs = 5 * 60 * 1e3;
5979
+ /**
5980
+ * Cached assembled navigator (Pipeline). createNavigator() reads strategy
5981
+ * docs and builds a fresh Pipeline every call — whose internal `_tagCache`
5982
+ * and `_cachedOrchestration` are designed to make replans cheap but never
5983
+ * survive, because the instance is discarded each run. Caching it lets those
5984
+ * caches persist across plan/replan within a session (SessionController holds
5985
+ * one CourseDB instance for the session's lifetime). Rebuilt on user change,
5986
+ * TTL expiry, or explicit invalidation after a strategy-doc write.
5987
+ */
5988
+ _cachedNavigator = null;
5989
+ _navigatorTtlMs = 5 * 60 * 1e3;
5970
5990
  setEphemeralHints(hints) {
5971
5991
  this._pendingHints = hints;
5972
5992
  }
5973
5993
  async getWeightedCards(limit) {
5974
5994
  const u = await this._getCurrentUser();
5975
5995
  try {
5976
- const navigator2 = await this.createNavigator(u);
5996
+ const { navigator: navigator2 } = await this._getCachedNavigator(u);
5977
5997
  if (this._pendingHints) {
5978
5998
  navigator2.setEphemeralHints(this._pendingHints);
5979
5999
  this._pendingHints = null;
5980
6000
  }
5981
- return navigator2.getWeightedCards(limit);
6001
+ const result = await navigator2.getWeightedCards(limit);
6002
+ return result;
5982
6003
  } catch (e) {
5983
6004
  logger.error(`[courseDB] Error getting weighted cards: ${e}`);
5984
6005
  throw e;
5985
6006
  }
5986
6007
  }
6008
+ /**
6009
+ * Return the assembled navigator, reusing the cached instance when possible.
6010
+ * Reuse preserves the Pipeline's per-session caches (tags, orchestration
6011
+ * context) across replans, which is the dominant per-replan cost once the
6012
+ * ELO-pool cost is removed. Rebuilds on user change or TTL expiry.
6013
+ */
6014
+ async _getCachedNavigator(user) {
6015
+ const userId = user.getUsername();
6016
+ const now = Date.now();
6017
+ if (this._cachedNavigator && this._cachedNavigator.userId === userId && now - this._cachedNavigator.builtAt < this._navigatorTtlMs) {
6018
+ return { navigator: this._cachedNavigator.navigator, cacheStatus: "hit" };
6019
+ }
6020
+ const navigator2 = await this.createNavigator(user);
6021
+ this._cachedNavigator = { navigator: navigator2, userId, builtAt: now };
6022
+ return { navigator: navigator2, cacheStatus: "miss" };
6023
+ }
6024
+ /**
6025
+ * Drop the cached navigator so the next getWeightedCards() rebuilds it.
6026
+ * Call after mutating this course's navigation strategy documents.
6027
+ */
6028
+ invalidateNavigatorCache() {
6029
+ this._cachedNavigator = null;
6030
+ }
5987
6031
  async getCardsCenteredAtELO(options = {
5988
6032
  limit: 99,
5989
6033
  elo: "user"
@@ -6006,20 +6050,31 @@ ${e.stack}` : JSON.stringify(e);
6006
6050
  } else {
6007
6051
  targetElo = options.elo;
6008
6052
  }
6009
- let cards = [];
6010
- let mult = 4;
6011
- let previousCount = -1;
6012
- let newCount = 0;
6013
- while (cards.length < options.limit && newCount !== previousCount) {
6014
- cards = await this.getCardsByELO(targetElo, mult * options.limit);
6015
- previousCount = newCount;
6016
- newCount = cards.length;
6017
- logger.debug(`Found ${cards.length} elo neighbor cards...`);
6018
- if (filter) {
6019
- cards = cards.filter(filter);
6020
- logger.debug(`Filtered to ${cards.length} cards...`);
6021
- }
6022
- mult *= 2;
6053
+ const POOL_SIZE = Math.max(2e3, options.limit * 4);
6054
+ const nowMs = Date.now();
6055
+ let cacheStatus = "hit";
6056
+ if (!this._eloPoolCache || nowMs - this._eloPoolCache.fetchedAt > this._eloPoolTtlMs) {
6057
+ const fetched = await this.getCardsByELO(targetElo, POOL_SIZE);
6058
+ if (fetched.length > 0) {
6059
+ this._eloPoolCache = { rows: fetched, fetchedAt: nowMs };
6060
+ }
6061
+ cacheStatus = "miss";
6062
+ }
6063
+ const rankAgainstCurrentElo = () => {
6064
+ const raw = this._eloPoolCache?.rows ?? [];
6065
+ const survivors = filter ? raw.filter((c) => filter(c)) : raw;
6066
+ return survivors.map((c) => ({ ...c })).sort(
6067
+ (a, b) => Math.abs((a.elo ?? targetElo) - targetElo) - Math.abs((b.elo ?? targetElo) - targetElo)
6068
+ );
6069
+ };
6070
+ let cards = rankAgainstCurrentElo();
6071
+ if (cards.length < options.limit && (cacheStatus === "hit" || !this._eloPoolCache)) {
6072
+ const fetched = await this.getCardsByELO(targetElo, POOL_SIZE);
6073
+ if (fetched.length > 0) {
6074
+ this._eloPoolCache = { rows: fetched, fetchedAt: nowMs };
6075
+ }
6076
+ cards = rankAgainstCurrentElo();
6077
+ cacheStatus = "refresh";
6023
6078
  }
6024
6079
  const selectedCards = [];
6025
6080
  while (selectedCards.length < options.limit && cards.length > 0) {
@@ -11290,6 +11345,7 @@ var ItemQueue = class {
11290
11345
 
11291
11346
  // src/study/SessionController.ts
11292
11347
  init_couch();
11348
+ init_core();
11293
11349
  init_recording();
11294
11350
 
11295
11351
  // src/util/index.ts
@@ -12716,6 +12772,7 @@ init_dataDirectory();
12716
12772
 
12717
12773
  // src/study/SessionController.ts
12718
12774
  init_navigators();
12775
+ init_Pipeline();
12719
12776
 
12720
12777
  // src/study/SourceMixer.ts
12721
12778
  var QuotaRoundRobinMixer = class {
@@ -13427,6 +13484,32 @@ var SessionController = class _SessionController extends Loggable {
13427
13484
  * each nextCard() draw. Set by replans that include `minFollowUpCards`.
13428
13485
  */
13429
13486
  _minCardsGuarantee = 0;
13487
+ /**
13488
+ * Session-durable scoring hints. Re-merged into every pipeline run for
13489
+ * the rest of the session (initial plan + every replan, including bare
13490
+ * auto-replans and the wedge-breaker), via `_applyHintsToSources`.
13491
+ *
13492
+ * Set by `setSessionHints()` (e.g. session-start post-lesson boost) or by
13493
+ * any replan carrying `ReplanOptions.sessionHints` (e.g. a just-failed
13494
+ * concept boost). Replace semantics, no decay — lives until overwritten
13495
+ * or session end. See `ReplanOptions.sessionHints` for rationale.
13496
+ *
13497
+ * Note: the controller-managed auto-excludes (current card, session
13498
+ * record, imminent draw) are intentionally NOT folded in here — those are
13499
+ * recomputed per-run in `_runReplan` and would otherwise go stale.
13500
+ */
13501
+ _sessionHints = null;
13502
+ /**
13503
+ * Consumer-supplied hooks invoked after each question response is processed.
13504
+ * Seeded from constructor options (threaded from
13505
+ * `StudySessionConfig.outcomeObservers`). See {@link OutcomeObserver}.
13506
+ */
13507
+ _outcomeObservers = [];
13508
+ /**
13509
+ * Lazily-built, stable capability object handed to observers. Bound to
13510
+ * `this`; constructed once so observers can rely on referential identity.
13511
+ */
13512
+ _sessionControls = null;
13430
13513
  startTime;
13431
13514
  endTime;
13432
13515
  _secondsRemaining;
@@ -13486,6 +13569,9 @@ var SessionController = class _SessionController extends Loggable {
13486
13569
  if (options?.initialReviewCap !== void 0) {
13487
13570
  this._initialReviewCap = options.initialReviewCap;
13488
13571
  }
13572
+ if (options?.outcomeObservers?.length) {
13573
+ this._outcomeObservers = [...options.outcomeObservers];
13574
+ }
13489
13575
  this.log(`Session constructed:
13490
13576
  startTime: ${this.startTime}
13491
13577
  endTime: ${this.endTime}
@@ -13613,6 +13699,7 @@ var SessionController = class _SessionController extends Loggable {
13613
13699
  if (opts.minFollowUpCards !== void 0) return true;
13614
13700
  if (opts.mode && opts.mode !== "replace") return true;
13615
13701
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
13702
+ if (opts.sessionHints !== void 0) return true;
13616
13703
  return false;
13617
13704
  }
13618
13705
  /**
@@ -13641,12 +13728,13 @@ var SessionController = class _SessionController extends Loggable {
13641
13728
  excludeSet.add(this.newQ.peek(0).cardID);
13642
13729
  }
13643
13730
  hints.excludeCards = [...excludeSet];
13644
- if (opts.hints) {
13645
- const hintsWithLabel = opts.label ? { ...opts.hints, _label: opts.label } : opts.hints;
13646
- for (const source of this.sources) {
13647
- source.setEphemeralHints?.(hintsWithLabel);
13648
- }
13731
+ if (opts.sessionHints !== void 0) {
13732
+ this._sessionHints = opts.sessionHints;
13733
+ this.log(
13734
+ `[Replan] Session hints ${opts.sessionHints ? "set" : "cleared"}: ${JSON.stringify(opts.sessionHints)}`
13735
+ );
13649
13736
  }
13737
+ this._applyHintsToSources(opts.hints, opts.label);
13650
13738
  const labelTag = opts.label ? ` [${opts.label}]` : "";
13651
13739
  this.log(
13652
13740
  `Mid-session replan requested${labelTag} (limit: ${opts.limit ?? "default"}, mode: ${opts.mode ?? "replace"}${opts.hints ? ", with hints" : ""})`
@@ -13657,6 +13745,100 @@ var SessionController = class _SessionController extends Loggable {
13657
13745
  }
13658
13746
  await this._executeReplan(opts);
13659
13747
  }
13748
+ /**
13749
+ * Set the session-durable scoring hints (replace semantics, no decay).
13750
+ *
13751
+ * Unlike a one-shot replan hint, these are re-merged into every pipeline
13752
+ * run for the rest of the session — including the initial plan when set
13753
+ * before `prepareSession()`, every replan, the bare auto-replans, and the
13754
+ * wedge-breaker. Pass `null` to clear.
13755
+ *
13756
+ * Typical callers:
13757
+ * - `StudySession` at session start, threading `StudySessionConfig.initHints`
13758
+ * (e.g. a post-lesson concept boost) — so the boost outlives the first
13759
+ * queue rebuild instead of being clobbered by the first auto-replan.
13760
+ * - A consumer view on a failure, boosting the just-failed concept tag.
13761
+ *
13762
+ * Does not itself trigger a replan; the next plan/replan picks it up.
13763
+ */
13764
+ setSessionHints(hints) {
13765
+ this._sessionHints = hints;
13766
+ this.log(`Session hints ${hints ? "set" : "cleared"}: ${JSON.stringify(hints)}`);
13767
+ }
13768
+ /**
13769
+ * Read the current session-durable hints (for read-modify-write callers,
13770
+ * e.g. an outcome observer that clamps a compounding boost).
13771
+ */
13772
+ getSessionHints() {
13773
+ return this._sessionHints;
13774
+ }
13775
+ /**
13776
+ * Merge `hints` into the durable session hints via the pipeline's
13777
+ * `mergeHints` (boosts multiply, require/exclude lists concat-dedup).
13778
+ * Convenience over get-then-set for the common additive case. Note the
13779
+ * multiplicative, no-decay semantics — clamp boost factors at the call
13780
+ * site if a repeatedly-emphasised tag could compound unboundedly.
13781
+ */
13782
+ mergeSessionHints(hints) {
13783
+ this._sessionHints = mergeHints2([this._sessionHints, hints]) ?? null;
13784
+ this.log(`Session hints merged: ${JSON.stringify(this._sessionHints)}`);
13785
+ }
13786
+ /**
13787
+ * Merge the durable `_sessionHints` with this run's one-shot hints and
13788
+ * push the result to every source for consumption on the next pipeline
13789
+ * run. Centralised so the initial plan and all replan paths apply session
13790
+ * emphasis identically. No-op when there are no hints of either kind.
13791
+ */
13792
+ _applyHintsToSources(oneShot, label) {
13793
+ const oneShotWithLabel = oneShot && label ? { ...oneShot, _label: label } : oneShot;
13794
+ const merged = mergeHints2([this._sessionHints, oneShotWithLabel]);
13795
+ if (!merged) return;
13796
+ for (const source of this.sources) {
13797
+ source.setEphemeralHints?.(merged);
13798
+ }
13799
+ }
13800
+ /**
13801
+ * Build (once) the stable capability object handed to outcome observers.
13802
+ * Methods are bound to `this`; the object identity is stable across calls
13803
+ * so observers may key off it.
13804
+ */
13805
+ _getSessionControls() {
13806
+ if (!this._sessionControls) {
13807
+ this._sessionControls = {
13808
+ getSessionHints: () => this.getSessionHints(),
13809
+ setSessionHints: (h) => this.setSessionHints(h),
13810
+ mergeSessionHints: (h) => this.mergeSessionHints(h),
13811
+ requestReplan: (opts) => this.requestReplan(opts)
13812
+ };
13813
+ }
13814
+ return this._sessionControls;
13815
+ }
13816
+ /**
13817
+ * Notify registered outcome observers about a processed response.
13818
+ *
13819
+ * Only question records are surfaced (non-question dismisses are skipped).
13820
+ * Observers run after ELO/SRS are recorded and before navigation. Each is
13821
+ * awaited but isolated in try/catch — a throwing observer is logged and
13822
+ * skipped, never wedging the session. Keep observers cheap and `void` any
13823
+ * long work (e.g. a triggered replan) to avoid stalling the draw.
13824
+ */
13825
+ async _notifyOutcomeObservers(record, currentCard, result) {
13826
+ if (this._outcomeObservers.length === 0) return;
13827
+ if (!isQuestionRecord(record)) return;
13828
+ const outcome = {
13829
+ record,
13830
+ card: currentCard.card,
13831
+ result
13832
+ };
13833
+ const controls = this._getSessionControls();
13834
+ for (const observer of this._outcomeObservers) {
13835
+ try {
13836
+ await observer(outcome, controls);
13837
+ } catch (e) {
13838
+ this.error("[OutcomeObserver] observer threw; ignoring", e);
13839
+ }
13840
+ }
13841
+ }
13660
13842
  /**
13661
13843
  * Run a replan, bypassing requestReplan()'s coalesce logic.
13662
13844
  *
@@ -13684,7 +13866,7 @@ var SessionController = class _SessionController extends Loggable {
13684
13866
  */
13685
13867
  normalizeReplanOptions(input) {
13686
13868
  if (!input) return {};
13687
- const replanKeys = ["hints", "limit", "mode", "label", "minFollowUpCards"];
13869
+ const replanKeys = ["hints", "sessionHints", "limit", "mode", "label", "minFollowUpCards"];
13688
13870
  const inputKeys = Object.keys(input);
13689
13871
  if (inputKeys.some((k) => replanKeys.includes(k))) {
13690
13872
  return input;
@@ -13836,6 +14018,9 @@ var SessionController = class _SessionController extends Loggable {
13836
14018
  const additive = options?.additive ?? false;
13837
14019
  const newLimit = options?.limit ?? this._defaultBatchLimit;
13838
14020
  const fetchLimit = replan ? newLimit : newLimit + this._initialReviewCap;
14021
+ if (!replan) {
14022
+ this._applyHintsToSources();
14023
+ }
13839
14024
  const batches = [];
13840
14025
  for (let i = 0; i < this.sources.length; i++) {
13841
14026
  const source = this.sources[i];
@@ -14116,7 +14301,7 @@ var SessionController = class _SessionController extends Loggable {
14116
14301
  const studySessionItem = {
14117
14302
  ...currentCard.item
14118
14303
  };
14119
- return await this.services.response.processResponse(
14304
+ const result = await this.services.response.processResponse(
14120
14305
  cardRecord,
14121
14306
  cardHistory,
14122
14307
  studySessionItem,
@@ -14128,6 +14313,8 @@ var SessionController = class _SessionController extends Loggable {
14128
14313
  maxSessionViews,
14129
14314
  sessionViews
14130
14315
  );
14316
+ await this._notifyOutcomeObservers(cardRecord, currentCard, result);
14317
+ return result;
14131
14318
  }
14132
14319
  dismissCurrentCard(action = "dismiss-success") {
14133
14320
  if (this._currentCard) {