@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.
- package/dist/{contentSource-Ht3N2f-y.d.ts → contentSource-Cplhv3bJ.d.ts} +1 -1
- package/dist/{contentSource-BMlMwSiG.d.cts → contentSource-kI9_jwTu.d.cts} +1 -1
- package/dist/core/index.d.cts +5 -5
- package/dist/core/index.d.ts +5 -5
- package/dist/core/index.js +76 -21
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +76 -21
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-BEqB8VBR.d.cts → dataLayerProvider-CiA2Rr0v.d.cts} +1 -1
- package/dist/{dataLayerProvider-DObSXjnf.d.ts → dataLayerProvider-DrBqOUa3.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +35 -3
- package/dist/impl/couch/index.d.ts +35 -3
- package/dist/impl/couch/index.js +76 -21
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +76 -21
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +4 -4
- package/dist/impl/static/index.d.ts +4 -4
- package/dist/impl/static/index.js +4 -5
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +4 -5
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/{index-BWvO-_rJ.d.ts → index-BLLT5BYE.d.ts} +1 -1
- package/dist/{index-Ba7hYbHj.d.cts → index-k9NFHpS1.d.cts} +1 -1
- package/dist/index.d.cts +164 -10
- package/dist/index.d.ts +164 -10
- package/dist/index.js +215 -28
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +215 -28
- package/dist/index.mjs.map +1 -1
- package/dist/{types-W8n-B6HG.d.cts → types-BFUa1pa3.d.cts} +1 -1
- package/dist/{types-CJrLM1Ew.d.ts → types-CHgpWQAY.d.ts} +1 -1
- package/dist/{types-legacy-JXDxinpU.d.cts → types-legacy-4tlwHnXo.d.cts} +1 -1
- package/dist/{types-legacy-JXDxinpU.d.ts → types-legacy-4tlwHnXo.d.ts} +1 -1
- package/dist/util/packer/index.d.cts +3 -3
- package/dist/util/packer/index.d.ts +3 -3
- package/docs/session-lifecycle-and-replan.md +418 -0
- package/package.json +3 -3
- package/src/core/navigators/Pipeline.ts +5 -1
- 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 +295 -13
- 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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
835
|
+
let cards = rankAgainstCurrentElo();
|
|
720
836
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
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?: {
|
|
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
|
-
//
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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') {
|