@vue-skuilder/db 0.2.7 → 0.2.9
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-Cplhv3bJ.d.ts → contentSource-C-0t0y0V.d.ts} +7 -0
- package/dist/{contentSource-kI9_jwTu.d.cts → contentSource-jSkcOt2s.d.cts} +7 -0
- package/dist/core/index.d.cts +67 -4
- package/dist/core/index.d.ts +67 -4
- package/dist/core/index.js +201 -39
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +198 -39
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-DrBqOUa3.d.ts → dataLayerProvider-BB0oi9T0.d.ts} +1 -1
- package/dist/{dataLayerProvider-CiA2Rr0v.d.cts → dataLayerProvider-BDClIrFC.d.cts} +1 -1
- package/dist/impl/couch/index.d.cts +2 -2
- package/dist/impl/couch/index.d.ts +2 -2
- package/dist/impl/couch/index.js +195 -39
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +195 -39
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +2 -2
- package/dist/impl/static/index.d.ts +2 -2
- package/dist/impl/static/index.js +195 -39
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +195 -39
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +115 -81
- package/dist/index.d.ts +115 -81
- package/dist/index.js +440 -251
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +437 -251
- package/dist/index.mjs.map +1 -1
- package/docs/navigators-architecture.md +29 -13
- package/package.json +3 -3
- package/src/core/interfaces/contentSource.ts +7 -0
- package/src/core/navigators/Pipeline.ts +93 -1
- package/src/core/navigators/PipelineDebugger.ts +11 -1
- package/src/core/navigators/SrsDebugger.ts +53 -0
- package/src/core/navigators/generators/prescribed.ts +76 -9
- package/src/core/navigators/generators/srs.ts +81 -37
- package/src/core/navigators/index.ts +9 -0
- package/src/study/SessionController.ts +260 -249
- package/src/study/SessionDebugger.ts +15 -25
- package/src/study/SessionOverlay.ts +108 -13
package/dist/index.mjs
CHANGED
|
@@ -944,6 +944,7 @@ __export(PipelineDebugger_exports, {
|
|
|
944
944
|
buildRunReport: () => buildRunReport,
|
|
945
945
|
captureRun: () => captureRun,
|
|
946
946
|
clearRunHistory: () => clearRunHistory,
|
|
947
|
+
getActivePipeline: () => getActivePipeline,
|
|
947
948
|
mountPipelineDebugger: () => mountPipelineDebugger,
|
|
948
949
|
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
949
950
|
registerPipelineForDebug: () => registerPipelineForDebug
|
|
@@ -951,6 +952,9 @@ __export(PipelineDebugger_exports, {
|
|
|
951
952
|
function registerPipelineForDebug(pipeline) {
|
|
952
953
|
_activePipeline = pipeline;
|
|
953
954
|
}
|
|
955
|
+
function getActivePipeline() {
|
|
956
|
+
return _activePipeline;
|
|
957
|
+
}
|
|
954
958
|
function clearRunHistory() {
|
|
955
959
|
runHistory.length = 0;
|
|
956
960
|
}
|
|
@@ -1766,6 +1770,30 @@ Example:
|
|
|
1766
1770
|
}
|
|
1767
1771
|
});
|
|
1768
1772
|
|
|
1773
|
+
// src/core/navigators/SrsDebugger.ts
|
|
1774
|
+
var SrsDebugger_exports = {};
|
|
1775
|
+
__export(SrsDebugger_exports, {
|
|
1776
|
+
captureSrsBacklog: () => captureSrsBacklog,
|
|
1777
|
+
clearSrsBacklogDebug: () => clearSrsBacklogDebug,
|
|
1778
|
+
getSrsBacklogDebug: () => getSrsBacklogDebug
|
|
1779
|
+
});
|
|
1780
|
+
function captureSrsBacklog(snapshot) {
|
|
1781
|
+
snapshots.set(snapshot.courseId, snapshot);
|
|
1782
|
+
}
|
|
1783
|
+
function getSrsBacklogDebug() {
|
|
1784
|
+
return [...snapshots.values()].sort((a, b) => b.timestamp - a.timestamp);
|
|
1785
|
+
}
|
|
1786
|
+
function clearSrsBacklogDebug() {
|
|
1787
|
+
snapshots.clear();
|
|
1788
|
+
}
|
|
1789
|
+
var snapshots;
|
|
1790
|
+
var init_SrsDebugger = __esm({
|
|
1791
|
+
"src/core/navigators/SrsDebugger.ts"() {
|
|
1792
|
+
"use strict";
|
|
1793
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
1794
|
+
}
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1769
1797
|
// src/core/navigators/generators/CompositeGenerator.ts
|
|
1770
1798
|
var CompositeGenerator_exports = {};
|
|
1771
1799
|
__export(CompositeGenerator_exports, {
|
|
@@ -2133,7 +2161,7 @@ function shuffleInPlace(arr) {
|
|
|
2133
2161
|
function pickTopByScore(cards, limit) {
|
|
2134
2162
|
return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
|
|
2135
2163
|
}
|
|
2136
|
-
var DEFAULT_FRESHNESS_WINDOW, DEFAULT_MAX_DIRECT_PER_RUN, DEFAULT_MAX_SUPPORT_PER_RUN, DEFAULT_HIERARCHY_DEPTH, DEFAULT_MIN_COUNT, DEFAULT_PRACTICE_MIN_COUNT, DEFAULT_MAX_PRACTICE_PER_RUN, BASE_TARGET_SCORE, BASE_SUPPORT_SCORE, DISCOVERED_SUPPORT_SCORE, BASE_PRACTICE_SCORE, MAX_TARGET_MULTIPLIER, MAX_SUPPORT_MULTIPLIER, PRESCRIBED_DEBUG_VERSION, PrescribedCardsGenerator;
|
|
2164
|
+
var DEFAULT_FRESHNESS_WINDOW, DEFAULT_MAX_DIRECT_PER_RUN, DEFAULT_MAX_SUPPORT_PER_RUN, DEFAULT_HIERARCHY_DEPTH, DEFAULT_MIN_COUNT, DEFAULT_PRACTICE_MIN_COUNT, DEFAULT_MAX_PRACTICE_PER_RUN, BASE_TARGET_SCORE, BASE_SUPPORT_SCORE, DISCOVERED_SUPPORT_SCORE, BASE_PRACTICE_SCORE, PRACTICE_BASE_MULT, MAX_PRACTICE_MULTIPLIER, PRACTICE_STALENESS_BUMP_PER_DAY, MAX_TARGET_MULTIPLIER, MAX_SUPPORT_MULTIPLIER, PRESCRIBED_DEBUG_VERSION, PrescribedCardsGenerator;
|
|
2137
2165
|
var init_prescribed = __esm({
|
|
2138
2166
|
"src/core/navigators/generators/prescribed.ts"() {
|
|
2139
2167
|
"use strict";
|
|
@@ -2150,6 +2178,9 @@ var init_prescribed = __esm({
|
|
|
2150
2178
|
BASE_SUPPORT_SCORE = 0.8;
|
|
2151
2179
|
DISCOVERED_SUPPORT_SCORE = 12;
|
|
2152
2180
|
BASE_PRACTICE_SCORE = 1;
|
|
2181
|
+
PRACTICE_BASE_MULT = 2;
|
|
2182
|
+
MAX_PRACTICE_MULTIPLIER = 4;
|
|
2183
|
+
PRACTICE_STALENESS_BUMP_PER_DAY = 0.5;
|
|
2153
2184
|
MAX_TARGET_MULTIPLIER = 8;
|
|
2154
2185
|
MAX_SUPPORT_MULTIPLIER = 4;
|
|
2155
2186
|
PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
|
|
@@ -2209,6 +2240,8 @@ var init_prescribed = __esm({
|
|
|
2209
2240
|
const emitted = [];
|
|
2210
2241
|
const emittedIds = /* @__PURE__ */ new Set();
|
|
2211
2242
|
const groupRuntimes = [];
|
|
2243
|
+
const priorPracticeDebt = progress.practiceDebt ?? {};
|
|
2244
|
+
const nextPracticeDebt = {};
|
|
2212
2245
|
for (const group of this.config.groups) {
|
|
2213
2246
|
const runtime = this.buildGroupRuntimeState({
|
|
2214
2247
|
group,
|
|
@@ -2266,10 +2299,13 @@ var init_prescribed = __esm({
|
|
|
2266
2299
|
userTagElo,
|
|
2267
2300
|
userGlobalElo,
|
|
2268
2301
|
activeIds,
|
|
2269
|
-
seenIds
|
|
2302
|
+
seenIds,
|
|
2303
|
+
priorPracticeDebt,
|
|
2304
|
+
nextPracticeDebt
|
|
2270
2305
|
});
|
|
2271
2306
|
emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
|
|
2272
2307
|
}
|
|
2308
|
+
nextState.practiceDebt = nextPracticeDebt;
|
|
2273
2309
|
const hintSummary = this.buildSupportHintSummary(groupRuntimes);
|
|
2274
2310
|
const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
|
|
2275
2311
|
boostTags: hintSummary.boostTags,
|
|
@@ -2603,9 +2639,16 @@ var init_prescribed = __esm({
|
|
|
2603
2639
|
* `practiceMinCount`), this resolves cards carrying that tag and emits them
|
|
2604
2640
|
* into the candidate pool. It exists because global-ELO retrieval
|
|
2605
2641
|
* systematically fails to fetch the (low-ELO) drill cards for a
|
|
2606
|
-
* freshly-introduced skill — putting them in the pool here
|
|
2607
|
-
*
|
|
2608
|
-
*
|
|
2642
|
+
* freshly-introduced skill — putting them in the pool here guarantees presence.
|
|
2643
|
+
*
|
|
2644
|
+
* Emphasis is a **practice-debt pressure** (parallel to SRS backlog pressure):
|
|
2645
|
+
* cards score `base × multiplier`, where the multiplier starts at
|
|
2646
|
+
* PRACTICE_BASE_MULT (so a few reps land promptly post-intro, competing with
|
|
2647
|
+
* pressured reviews) and escalates by how long the debt has stayed open
|
|
2648
|
+
* (per-tag, time-based via `priorPracticeDebt`/`nextPracticeDebt`), clamped at
|
|
2649
|
+
* MAX_PRACTICE_MULTIPLIER. The debt is durable and self-discharges the instant
|
|
2650
|
+
* the skill reaches `practiceMinCount` — so this no longer relies on the
|
|
2651
|
+
* session-scoped intro boost to actually surface.
|
|
2609
2652
|
*
|
|
2610
2653
|
* Fully data-driven: the unlock relation comes from the hierarchy config and
|
|
2611
2654
|
* practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
|
|
@@ -2620,7 +2663,9 @@ var init_prescribed = __esm({
|
|
|
2620
2663
|
userTagElo,
|
|
2621
2664
|
userGlobalElo,
|
|
2622
2665
|
activeIds,
|
|
2623
|
-
seenIds
|
|
2666
|
+
seenIds,
|
|
2667
|
+
priorPracticeDebt,
|
|
2668
|
+
nextPracticeDebt
|
|
2624
2669
|
} = args;
|
|
2625
2670
|
const patterns = group.practiceTagPatterns ?? [];
|
|
2626
2671
|
if (patterns.length === 0) return [];
|
|
@@ -2630,6 +2675,20 @@ var init_prescribed = __esm({
|
|
|
2630
2675
|
(tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
|
|
2631
2676
|
);
|
|
2632
2677
|
if (practiceTags.length === 0) return [];
|
|
2678
|
+
const now = Date.now();
|
|
2679
|
+
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
2680
|
+
const tagMultiplier = /* @__PURE__ */ new Map();
|
|
2681
|
+
for (const tag of practiceTags) {
|
|
2682
|
+
const firstOwedAt = priorPracticeDebt[tag] ?? isoNow();
|
|
2683
|
+
nextPracticeDebt[tag] = firstOwedAt;
|
|
2684
|
+
const staleDays = Math.max(0, (now - new Date(firstOwedAt).getTime()) / DAY_MS);
|
|
2685
|
+
const mult = clamp(
|
|
2686
|
+
PRACTICE_BASE_MULT + staleDays * PRACTICE_STALENESS_BUMP_PER_DAY,
|
|
2687
|
+
PRACTICE_BASE_MULT,
|
|
2688
|
+
MAX_PRACTICE_MULTIPLIER
|
|
2689
|
+
);
|
|
2690
|
+
tagMultiplier.set(tag, mult);
|
|
2691
|
+
}
|
|
2633
2692
|
const practiceCardIds = this.findDiscoveredSupportCards({
|
|
2634
2693
|
supportTags: practiceTags,
|
|
2635
2694
|
cardsByTag,
|
|
@@ -2645,18 +2704,25 @@ var init_prescribed = __esm({
|
|
|
2645
2704
|
const cards = [];
|
|
2646
2705
|
for (const cardId of practiceCardIds) {
|
|
2647
2706
|
emittedIds.add(cardId);
|
|
2707
|
+
let mult = PRACTICE_BASE_MULT;
|
|
2708
|
+
for (const tag of practiceTags) {
|
|
2709
|
+
if (cardsByTag.get(tag)?.includes(cardId) ?? false) {
|
|
2710
|
+
mult = Math.max(mult, tagMultiplier.get(tag) ?? PRACTICE_BASE_MULT);
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
const score = BASE_PRACTICE_SCORE * mult;
|
|
2648
2714
|
cards.push({
|
|
2649
2715
|
cardId,
|
|
2650
2716
|
courseId,
|
|
2651
|
-
score
|
|
2717
|
+
score,
|
|
2652
2718
|
provenance: [
|
|
2653
2719
|
{
|
|
2654
2720
|
strategy: "prescribed",
|
|
2655
2721
|
strategyName: this.strategyName || this.name,
|
|
2656
2722
|
strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
|
|
2657
2723
|
action: "generated",
|
|
2658
|
-
score
|
|
2659
|
-
reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
|
|
2724
|
+
score,
|
|
2725
|
+
reason: `mode=practice;group=${group.id};debtMult=\xD7${mult.toFixed(2)};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
|
|
2660
2726
|
}
|
|
2661
2727
|
]
|
|
2662
2728
|
});
|
|
@@ -2830,14 +2896,15 @@ __export(srs_exports, {
|
|
|
2830
2896
|
default: () => SRSNavigator
|
|
2831
2897
|
});
|
|
2832
2898
|
import moment3 from "moment";
|
|
2833
|
-
var DEFAULT_HEALTHY_BACKLOG,
|
|
2899
|
+
var DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_MULTIPLIER, SRSNavigator;
|
|
2834
2900
|
var init_srs = __esm({
|
|
2835
2901
|
"src/core/navigators/generators/srs.ts"() {
|
|
2836
2902
|
"use strict";
|
|
2837
2903
|
init_navigators();
|
|
2904
|
+
init_SrsDebugger();
|
|
2838
2905
|
init_logger();
|
|
2839
2906
|
DEFAULT_HEALTHY_BACKLOG = 20;
|
|
2840
|
-
|
|
2907
|
+
MAX_BACKLOG_MULTIPLIER = 2;
|
|
2841
2908
|
SRSNavigator = class extends ContentNavigator {
|
|
2842
2909
|
/** Human-readable name for CardGenerator interface */
|
|
2843
2910
|
name;
|
|
@@ -2904,9 +2971,18 @@ var init_srs = __esm({
|
|
|
2904
2971
|
}
|
|
2905
2972
|
}
|
|
2906
2973
|
}
|
|
2907
|
-
const
|
|
2974
|
+
const backlogMultiplier = this.computeBacklogMultiplier(dueReviews.length);
|
|
2975
|
+
const notDue = reviews.filter((r) => !now.isAfter(moment3.utc(r.reviewTime)));
|
|
2976
|
+
let nextDueIn = null;
|
|
2977
|
+
if (notDue.length > 0) {
|
|
2978
|
+
const next = notDue.reduce(
|
|
2979
|
+
(a, b) => moment3.utc(a.reviewTime).isBefore(moment3.utc(b.reviewTime)) ? a : b
|
|
2980
|
+
);
|
|
2981
|
+
const until = moment3.duration(moment3.utc(next.reviewTime).diff(now));
|
|
2982
|
+
nextDueIn = until.asHours() < 1 ? `${Math.round(until.asMinutes())}m` : until.asHours() < 24 ? `${Math.round(until.asHours())}h` : `${Math.round(until.asDays())}d`;
|
|
2983
|
+
}
|
|
2908
2984
|
if (dueReviews.length > 0) {
|
|
2909
|
-
const pressureNote =
|
|
2985
|
+
const pressureNote = backlogMultiplier > 1 ? ` [backlog pressure: \xD7${backlogMultiplier.toFixed(2)}]` : ` [healthy backlog]`;
|
|
2910
2986
|
logger.info(
|
|
2911
2987
|
`[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
|
|
2912
2988
|
);
|
|
@@ -2925,7 +3001,7 @@ var init_srs = __esm({
|
|
|
2925
3001
|
logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
|
|
2926
3002
|
}
|
|
2927
3003
|
const scored = dueReviews.map((review) => {
|
|
2928
|
-
const { score, reason } = this.computeUrgencyScore(review, now,
|
|
3004
|
+
const { score, reason } = this.computeUrgencyScore(review, now, backlogMultiplier);
|
|
2929
3005
|
return {
|
|
2930
3006
|
cardId: review.cardId,
|
|
2931
3007
|
courseId: review.courseId,
|
|
@@ -2943,30 +3019,42 @@ var init_srs = __esm({
|
|
|
2943
3019
|
]
|
|
2944
3020
|
};
|
|
2945
3021
|
});
|
|
2946
|
-
|
|
3022
|
+
const sorted = scored.sort((a, b) => b.score - a.score);
|
|
3023
|
+
captureSrsBacklog({
|
|
3024
|
+
courseId,
|
|
3025
|
+
scheduledTotal: reviews.length,
|
|
3026
|
+
dueNow: dueReviews.length,
|
|
3027
|
+
healthyBacklog: this.healthyBacklog,
|
|
3028
|
+
backlogMultiplier,
|
|
3029
|
+
maxBacklogMultiplier: MAX_BACKLOG_MULTIPLIER,
|
|
3030
|
+
topReviewScore: sorted.length > 0 ? sorted[0].score : null,
|
|
3031
|
+
nextDueIn,
|
|
3032
|
+
timestamp: Date.now()
|
|
3033
|
+
});
|
|
3034
|
+
return { cards: sorted.slice(0, limit) };
|
|
2947
3035
|
}
|
|
2948
3036
|
/**
|
|
2949
|
-
* Compute backlog pressure based on number of due reviews.
|
|
3037
|
+
* Compute the multiplicative backlog pressure based on number of due reviews.
|
|
2950
3038
|
*
|
|
2951
|
-
*
|
|
2952
|
-
* and
|
|
3039
|
+
* ×1.0 at or below the healthy threshold (no boost), increasing linearly above
|
|
3040
|
+
* it and maxing out at MAX_BACKLOG_MULTIPLIER at 3× the healthy backlog.
|
|
2953
3041
|
*
|
|
2954
|
-
* Examples (with default healthyBacklog=20):
|
|
2955
|
-
* - 10 due reviews →
|
|
2956
|
-
* - 20 due reviews →
|
|
2957
|
-
* - 40 due reviews →
|
|
2958
|
-
* - 60 due reviews →
|
|
3042
|
+
* Examples (with default healthyBacklog=20, MAX_BACKLOG_MULTIPLIER=2.0):
|
|
3043
|
+
* - 10 due reviews → ×1.00 (healthy)
|
|
3044
|
+
* - 20 due reviews → ×1.00 (at threshold)
|
|
3045
|
+
* - 40 due reviews → ×1.50 (2x threshold)
|
|
3046
|
+
* - 60 due reviews → ×2.00 (3x threshold, maxed)
|
|
2959
3047
|
*
|
|
2960
3048
|
* @param dueCount - Number of reviews currently due
|
|
2961
|
-
* @returns
|
|
3049
|
+
* @returns Multiplier applied to review urgency (1.0 to MAX_BACKLOG_MULTIPLIER)
|
|
2962
3050
|
*/
|
|
2963
|
-
|
|
3051
|
+
computeBacklogMultiplier(dueCount) {
|
|
2964
3052
|
if (dueCount <= this.healthyBacklog) {
|
|
2965
|
-
return
|
|
3053
|
+
return 1;
|
|
2966
3054
|
}
|
|
2967
3055
|
const excess = dueCount - this.healthyBacklog;
|
|
2968
|
-
const
|
|
2969
|
-
return Math.min(
|
|
3056
|
+
const multiplier = 1 + excess / this.healthyBacklog * ((MAX_BACKLOG_MULTIPLIER - 1) / 2);
|
|
3057
|
+
return Math.min(MAX_BACKLOG_MULTIPLIER, multiplier);
|
|
2970
3058
|
}
|
|
2971
3059
|
/**
|
|
2972
3060
|
* Compute urgency score for a review card.
|
|
@@ -2981,19 +3069,20 @@ var init_srs = __esm({
|
|
|
2981
3069
|
* - 30 days (720h) → ~0.56
|
|
2982
3070
|
* - 180 days → ~0.30
|
|
2983
3071
|
*
|
|
2984
|
-
* 3. Backlog pressure = global
|
|
2985
|
-
*
|
|
2986
|
-
* - At 2x healthy: +0.25
|
|
2987
|
-
* - At 3x+ healthy: +0.50 (max)
|
|
3072
|
+
* 3. Backlog pressure = global *multiplier* when review backlog exceeds the
|
|
3073
|
+
* healthy threshold (×1.0 healthy → up to MAX_BACKLOG_MULTIPLIER at 3×).
|
|
2988
3074
|
*
|
|
2989
|
-
* Combined: base 0.5 +
|
|
2990
|
-
*
|
|
3075
|
+
* Combined: (base 0.5 + urgency factors * 0.45) × backlog multiplier.
|
|
3076
|
+
* Per-card range before pressure: ~0.57–0.95. NOT clamped to 1.0 — under a
|
|
3077
|
+
* heavy backlog reviews scale onto the open scale to compete with (and exceed)
|
|
3078
|
+
* new cards; what keeps them from running away is the bounded multiplier, not
|
|
3079
|
+
* a hard ceiling.
|
|
2991
3080
|
*
|
|
2992
3081
|
* @param review - The scheduled card to score
|
|
2993
3082
|
* @param now - Current time
|
|
2994
|
-
* @param
|
|
3083
|
+
* @param backlogMultiplier - Pre-computed backlog multiplier (1.0 to MAX_BACKLOG_MULTIPLIER)
|
|
2995
3084
|
*/
|
|
2996
|
-
computeUrgencyScore(review, now,
|
|
3085
|
+
computeUrgencyScore(review, now, backlogMultiplier) {
|
|
2997
3086
|
const scheduledAt = moment3.utc(review.scheduledAt);
|
|
2998
3087
|
const due = moment3.utc(review.reviewTime);
|
|
2999
3088
|
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
@@ -3003,15 +3092,15 @@ var init_srs = __esm({
|
|
|
3003
3092
|
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
3004
3093
|
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
3005
3094
|
const baseScore = 0.5 + urgency * 0.45;
|
|
3006
|
-
const score =
|
|
3095
|
+
const score = baseScore * backlogMultiplier;
|
|
3007
3096
|
const reasonParts = [
|
|
3008
3097
|
`${Math.round(hoursOverdue)}h overdue`,
|
|
3009
3098
|
`interval: ${Math.round(intervalHours)}h`,
|
|
3010
3099
|
`relative: ${relativeOverdue.toFixed(2)}`,
|
|
3011
3100
|
`recency: ${recencyFactor.toFixed(2)}`
|
|
3012
3101
|
];
|
|
3013
|
-
if (
|
|
3014
|
-
reasonParts.push(`backlog:
|
|
3102
|
+
if (backlogMultiplier > 1) {
|
|
3103
|
+
reasonParts.push(`backlog: \xD7${backlogMultiplier.toFixed(2)}`);
|
|
3015
3104
|
}
|
|
3016
3105
|
reasonParts.push("review");
|
|
3017
3106
|
const reason = reasonParts.join(", ");
|
|
@@ -4983,6 +5072,68 @@ var init_Pipeline = __esm({
|
|
|
4983
5072
|
// ---------------------------------------------------------------------------
|
|
4984
5073
|
// Card-space diagnostic
|
|
4985
5074
|
// ---------------------------------------------------------------------------
|
|
5075
|
+
/**
|
|
5076
|
+
* Commit-free forecast: score the user's full card space through the filter
|
|
5077
|
+
* chain and return the cards that are currently *reachable* (score >=
|
|
5078
|
+
* threshold), optionally nudged by caller-supplied hints and/or restricted
|
|
5079
|
+
* to cards the user hasn't seen yet.
|
|
5080
|
+
*
|
|
5081
|
+
* This is a GENERIC primitive — it returns scored, tag-hydrated cards and
|
|
5082
|
+
* stops there. It has no knowledge of any particular tag convention; callers
|
|
5083
|
+
* decide what the surviving cards mean (e.g. filter to their own "intro"
|
|
5084
|
+
* tag family). Nothing is written and no session is started.
|
|
5085
|
+
*
|
|
5086
|
+
* The optional `hints` are the "out-of-band kick": they run through the same
|
|
5087
|
+
* {@link applyHints} path a live replan uses, so the two semantics carry over —
|
|
5088
|
+
* - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
|
|
5089
|
+
* stays out), and
|
|
5090
|
+
* - `requireTags`/`requireCards` inject from the full pre-filter pool,
|
|
5091
|
+
* *bypassing* gating (use when you want a card regardless of reachability).
|
|
5092
|
+
* Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
|
|
5093
|
+
* user has already seen — pass `unseenOnly: false` if that matters.
|
|
5094
|
+
*
|
|
5095
|
+
* Cost note: like {@link diagnoseCardSpace}, this scans every card through the
|
|
5096
|
+
* filters, so it's heavier than a normal replan. Intended for one-shot
|
|
5097
|
+
* out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
|
|
5098
|
+
*
|
|
5099
|
+
* @param opts.hints Optional ephemeral hints to apply after the filter chain.
|
|
5100
|
+
* @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
|
|
5101
|
+
* @param opts.threshold Min score to count as reachable (default 0.10).
|
|
5102
|
+
* @param opts.limit Optional cap on results (already sorted desc).
|
|
5103
|
+
*/
|
|
5104
|
+
async forecast(opts) {
|
|
5105
|
+
const threshold = opts?.threshold ?? 0.1;
|
|
5106
|
+
const unseenOnly = opts?.unseenOnly ?? true;
|
|
5107
|
+
const courseId = this.course.getCourseID();
|
|
5108
|
+
const allCardIds = await this.course.getAllCardIds();
|
|
5109
|
+
let cards = allCardIds.map((cardId) => ({
|
|
5110
|
+
cardId,
|
|
5111
|
+
courseId,
|
|
5112
|
+
score: 1,
|
|
5113
|
+
provenance: []
|
|
5114
|
+
}));
|
|
5115
|
+
cards = await this.hydrateTags(cards);
|
|
5116
|
+
const fullPool = cards.slice();
|
|
5117
|
+
const context = await this.buildContext();
|
|
5118
|
+
for (const filter of this.filters) {
|
|
5119
|
+
cards = await filter.transform(cards, context);
|
|
5120
|
+
}
|
|
5121
|
+
if (opts?.hints) {
|
|
5122
|
+
cards = this.applyHints(cards, opts.hints, fullPool);
|
|
5123
|
+
}
|
|
5124
|
+
cards = cards.filter((c) => c.score >= threshold);
|
|
5125
|
+
if (unseenOnly) {
|
|
5126
|
+
let encountered;
|
|
5127
|
+
try {
|
|
5128
|
+
encountered = new Set(await this.user.getSeenCards(courseId));
|
|
5129
|
+
} catch {
|
|
5130
|
+
encountered = /* @__PURE__ */ new Set();
|
|
5131
|
+
}
|
|
5132
|
+
cards = cards.filter((c) => !encountered.has(c.cardId));
|
|
5133
|
+
}
|
|
5134
|
+
cards.sort((a, b) => b.score - a.score);
|
|
5135
|
+
return opts?.limit ? cards.slice(0, opts.limit) : cards;
|
|
5136
|
+
}
|
|
4986
5137
|
/**
|
|
4987
5138
|
* Scan every card in the course through the filter chain and report
|
|
4988
5139
|
* how many are "well indicated" (score >= threshold) for the current user.
|
|
@@ -5247,6 +5398,7 @@ var init_3 = __esm({
|
|
|
5247
5398
|
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
5248
5399
|
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
5249
5400
|
"./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
|
|
5401
|
+
"./SrsDebugger.ts": () => Promise.resolve().then(() => (init_SrsDebugger(), SrsDebugger_exports)),
|
|
5250
5402
|
"./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
|
|
5251
5403
|
"./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
|
|
5252
5404
|
"./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
|
|
@@ -5279,11 +5431,14 @@ __export(navigators_exports, {
|
|
|
5279
5431
|
NavigatorRole: () => NavigatorRole,
|
|
5280
5432
|
NavigatorRoles: () => NavigatorRoles,
|
|
5281
5433
|
Navigators: () => Navigators,
|
|
5434
|
+
clearSrsBacklogDebug: () => clearSrsBacklogDebug,
|
|
5282
5435
|
diversityRerank: () => diversityRerank,
|
|
5436
|
+
getActivePipeline: () => getActivePipeline,
|
|
5283
5437
|
getCardOrigin: () => getCardOrigin,
|
|
5284
5438
|
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
5285
5439
|
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
5286
5440
|
getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
|
|
5441
|
+
getSrsBacklogDebug: () => getSrsBacklogDebug,
|
|
5287
5442
|
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
5288
5443
|
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
5289
5444
|
isFilter: () => isFilter,
|
|
@@ -5365,6 +5520,7 @@ var init_navigators = __esm({
|
|
|
5365
5520
|
"use strict";
|
|
5366
5521
|
init_diversityRerank();
|
|
5367
5522
|
init_PipelineDebugger();
|
|
5523
|
+
init_SrsDebugger();
|
|
5368
5524
|
init_logger();
|
|
5369
5525
|
init_();
|
|
5370
5526
|
init_2();
|
|
@@ -13271,6 +13427,7 @@ mountMixerDebugger();
|
|
|
13271
13427
|
// src/study/SessionDebugger.ts
|
|
13272
13428
|
init_logger();
|
|
13273
13429
|
init_PipelineDebugger();
|
|
13430
|
+
init_SrsDebugger();
|
|
13274
13431
|
|
|
13275
13432
|
// src/study/SessionOverlay.ts
|
|
13276
13433
|
init_logger();
|
|
@@ -13292,8 +13449,7 @@ var lastSnapshot = null;
|
|
|
13292
13449
|
var copyFlashUntil = 0;
|
|
13293
13450
|
var minified = false;
|
|
13294
13451
|
var expanded = {
|
|
13295
|
-
|
|
13296
|
-
newQ: false,
|
|
13452
|
+
supplyQ: false,
|
|
13297
13453
|
failedQ: false,
|
|
13298
13454
|
drawn: false
|
|
13299
13455
|
};
|
|
@@ -13362,7 +13518,7 @@ function render() {
|
|
|
13362
13518
|
attachHandlers();
|
|
13363
13519
|
return;
|
|
13364
13520
|
}
|
|
13365
|
-
overlayEl.innerHTML = headerHtml() + replanHtml(s) + metaHtml(s) + hintsHtml(s.sessionHints) +
|
|
13521
|
+
overlayEl.innerHTML = headerHtml() + replanHtml(s) + metaHtml(s) + hintsHtml(s.sessionHints) + backlogHtml(s.reviewBacklog) + queueHtml("supplyQ", "supplyQ", s.supplyQ) + queueHtml("failedQ", "failedQ", s.failedQ) + drawnHtml("drawn", s.drawnCards);
|
|
13366
13522
|
attachHandlers();
|
|
13367
13523
|
}
|
|
13368
13524
|
function attachHandlers() {
|
|
@@ -13454,6 +13610,29 @@ function hintsHtml(h) {
|
|
|
13454
13610
|
const body = parts.length ? parts.map((p) => `<div style="margin-left:6px">${p}</div>`).join("") : `<div style="margin-left:6px;opacity:.6">none</div>`;
|
|
13455
13611
|
return `<div style="margin-bottom:6px"><div style="color:#86efac">sessionHints</div>${body}</div>`;
|
|
13456
13612
|
}
|
|
13613
|
+
function backlogHtml(backlog) {
|
|
13614
|
+
if (!backlog.length) return "";
|
|
13615
|
+
const rows = backlog.map((b) => {
|
|
13616
|
+
const maxed = b.backlogMultiplier >= b.maxBacklogMultiplier - 1e-9;
|
|
13617
|
+
const multColor = b.backlogMultiplier <= 1 ? "#86efac" : maxed ? "#fca5a5" : "#fcd34d";
|
|
13618
|
+
const headroom = maxed ? "maxed \u2014 boosts decide order until they relax" : b.backlogMultiplier > 1 ? "climbing as backlog grows" : "healthy \u2014 no pressure";
|
|
13619
|
+
const top = b.topReviewScore !== null ? b.topReviewScore.toFixed(2) : "\u2014";
|
|
13620
|
+
const next = b.nextDueIn ? ` <span style="opacity:.6">\xB7 next due ${esc(b.nextDueIn)}</span>` : "";
|
|
13621
|
+
return `<div style="margin-left:6px"><span style="opacity:.7">${esc(b.courseId.slice(0, 8))}</span> due ${b.dueNow}/${b.scheduledTotal} <span style="opacity:.6">(healthy ${b.healthyBacklog})</span>${next}<div style="margin-left:6px">pressure <span style="color:${multColor}">\xD7${b.backlogMultiplier.toFixed(2)}/${b.maxBacklogMultiplier.toFixed(2)}</span> <span style="opacity:.6">${headroom} \xB7 top review ${top}</span></div></div>`;
|
|
13622
|
+
}).join("");
|
|
13623
|
+
return `<div style="margin-bottom:6px"><div style="color:#93c5fd">review backpressure</div>${rows}</div>`;
|
|
13624
|
+
}
|
|
13625
|
+
function fmtScore(score) {
|
|
13626
|
+
if (score === void 0) return "";
|
|
13627
|
+
if (!Number.isFinite(score)) return "REQ";
|
|
13628
|
+
return score.toFixed(2);
|
|
13629
|
+
}
|
|
13630
|
+
function queueItemHtml(item) {
|
|
13631
|
+
const tagColor = item.origin === "review" ? "#93c5fd" : "#fcd34d";
|
|
13632
|
+
const score = fmtScore(item.score);
|
|
13633
|
+
const label = `${item.origin === "review" ? "r" : "n"}${score ? " " + score : ""}`;
|
|
13634
|
+
return `${esc(item.cardID)}<span style="color:${tagColor};opacity:.85"> [${label}]</span>`;
|
|
13635
|
+
}
|
|
13457
13636
|
function queueHtml(key, label, q) {
|
|
13458
13637
|
const collapsible = q.length > INLINE_THRESHOLD;
|
|
13459
13638
|
const isOpen = collapsible && expanded[key];
|
|
@@ -13468,7 +13647,7 @@ function queueHtml(key, label, q) {
|
|
|
13468
13647
|
const shown = isOpen ? q.cards : q.cards.slice(0, INLINE_THRESHOLD);
|
|
13469
13648
|
const hiddenCount = q.length - shown.length;
|
|
13470
13649
|
const listMarginBottom = collapsible ? 2 : 6;
|
|
13471
|
-
let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">` + shown.map((c) => `<li style="white-space:nowrap">${
|
|
13650
|
+
let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">` + shown.map((c) => `<li style="white-space:nowrap">${queueItemHtml(c)}</li>`).join("") + `</ol>`;
|
|
13472
13651
|
if (collapsible) {
|
|
13473
13652
|
const footer = isOpen ? "\u25BE show less" : `\u2026 +${hiddenCount} more`;
|
|
13474
13653
|
body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
|
|
@@ -13531,13 +13710,29 @@ function snapshotToText(s) {
|
|
|
13531
13710
|
if (h.excludeCards?.length) hintParts.push(` excludeCards: ${h.excludeCards.join(", ")}`);
|
|
13532
13711
|
}
|
|
13533
13712
|
lines.push(hintParts.length ? hintParts.join("\n") : " none");
|
|
13713
|
+
if (s.reviewBacklog.length) {
|
|
13714
|
+
lines.push("");
|
|
13715
|
+
lines.push("review backpressure:");
|
|
13716
|
+
for (const b of s.reviewBacklog) {
|
|
13717
|
+
const maxed = b.backlogMultiplier >= b.maxBacklogMultiplier - 1e-9;
|
|
13718
|
+
const headroom = maxed ? "maxed (boosts decide order)" : b.backlogMultiplier > 1 ? "climbing" : "healthy";
|
|
13719
|
+
const top = b.topReviewScore !== null ? b.topReviewScore.toFixed(2) : "\u2014";
|
|
13720
|
+
const next = b.nextDueIn ? `, next due ${b.nextDueIn}` : "";
|
|
13721
|
+
lines.push(
|
|
13722
|
+
` ${b.courseId.slice(0, 8)}: due ${b.dueNow}/${b.scheduledTotal} (healthy ${b.healthyBacklog})${next}; pressure \xD7${b.backlogMultiplier.toFixed(2)}/${b.maxBacklogMultiplier.toFixed(2)} ${headroom}; top review ${top}`
|
|
13723
|
+
);
|
|
13724
|
+
}
|
|
13725
|
+
}
|
|
13534
13726
|
const queueText = (label, q) => {
|
|
13535
13727
|
lines.push("");
|
|
13536
13728
|
lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
|
|
13537
|
-
q.cards.forEach((c, i) =>
|
|
13729
|
+
q.cards.forEach((c, i) => {
|
|
13730
|
+
const score = fmtScore(c.score);
|
|
13731
|
+
const tag = `${c.origin === "review" ? "r" : "n"}${score ? " " + score : ""}`;
|
|
13732
|
+
lines.push(` ${i + 1}. ${c.cardID} [${tag}]`);
|
|
13733
|
+
});
|
|
13538
13734
|
};
|
|
13539
|
-
queueText("
|
|
13540
|
-
queueText("newQ", s.newQ);
|
|
13735
|
+
queueText("supplyQ", s.supplyQ);
|
|
13541
13736
|
queueText("failedQ", s.failedQ);
|
|
13542
13737
|
lines.push("");
|
|
13543
13738
|
lines.push(`drawn: ${s.drawnCards.length}`);
|
|
@@ -13562,16 +13757,16 @@ function esc(value) {
|
|
|
13562
13757
|
var activeSession = null;
|
|
13563
13758
|
var sessionHistory = [];
|
|
13564
13759
|
var MAX_HISTORY = 5;
|
|
13565
|
-
function startSessionTracking(
|
|
13760
|
+
function startSessionTracking(supplyQLength, failedQLength) {
|
|
13566
13761
|
clearRunHistory();
|
|
13762
|
+
clearSrsBacklogDebug();
|
|
13567
13763
|
const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
13568
13764
|
activeSession = {
|
|
13569
13765
|
sessionId,
|
|
13570
13766
|
startTime: /* @__PURE__ */ new Date(),
|
|
13571
13767
|
initialQueues: {
|
|
13572
13768
|
timestamp: /* @__PURE__ */ new Date(),
|
|
13573
|
-
|
|
13574
|
-
newQLength,
|
|
13769
|
+
supplyQLength,
|
|
13575
13770
|
failedQLength
|
|
13576
13771
|
},
|
|
13577
13772
|
presentations: [],
|
|
@@ -13595,17 +13790,15 @@ function recordCardPresentation(cardId, courseId, courseName, origin, queueSourc
|
|
|
13595
13790
|
score
|
|
13596
13791
|
});
|
|
13597
13792
|
}
|
|
13598
|
-
function snapshotQueues(
|
|
13793
|
+
function snapshotQueues(supplyQLength, failedQLength, supplyQNext3) {
|
|
13599
13794
|
if (!activeSession) {
|
|
13600
13795
|
return;
|
|
13601
13796
|
}
|
|
13602
13797
|
activeSession.queueSnapshots.push({
|
|
13603
13798
|
timestamp: /* @__PURE__ */ new Date(),
|
|
13604
|
-
|
|
13605
|
-
newQLength,
|
|
13799
|
+
supplyQLength,
|
|
13606
13800
|
failedQLength,
|
|
13607
|
-
|
|
13608
|
-
newQNext3
|
|
13801
|
+
supplyQNext3
|
|
13609
13802
|
});
|
|
13610
13803
|
}
|
|
13611
13804
|
function endSessionTracking() {
|
|
@@ -13627,13 +13820,9 @@ function showCurrentQueue() {
|
|
|
13627
13820
|
}
|
|
13628
13821
|
const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
|
|
13629
13822
|
console.group("\u{1F4CA} Current Queue State");
|
|
13630
|
-
logger.info(`
|
|
13631
|
-
if (latest.
|
|
13632
|
-
logger.info(` Next: ${latest.
|
|
13633
|
-
}
|
|
13634
|
-
logger.info(`New Queue: ${latest.newQLength} cards`);
|
|
13635
|
-
if (latest.newQNext3 && latest.newQNext3.length > 0) {
|
|
13636
|
-
logger.info(` Next: ${latest.newQNext3.join(", ")}`);
|
|
13823
|
+
logger.info(`Supply Queue: ${latest.supplyQLength} cards`);
|
|
13824
|
+
if (latest.supplyQNext3 && latest.supplyQNext3.length > 0) {
|
|
13825
|
+
logger.info(` Next: ${latest.supplyQNext3.join(", ")}`);
|
|
13637
13826
|
}
|
|
13638
13827
|
logger.info(`Failed Queue: ${latest.failedQLength} cards`);
|
|
13639
13828
|
console.groupEnd();
|
|
@@ -13850,15 +14039,6 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13850
14039
|
* Individual replans can override via `ReplanOptions.limit`.
|
|
13851
14040
|
*/
|
|
13852
14041
|
_defaultBatchLimit = 20;
|
|
13853
|
-
/**
|
|
13854
|
-
* Maximum number of reviews enqueued at session start. Reviews live
|
|
13855
|
-
* outside the replan flow — the queue drains via consumption and is
|
|
13856
|
-
* not refilled mid-session. The session timer caps total review
|
|
13857
|
-
* exposure, so overfilling here is intentional. Default is generous
|
|
13858
|
-
* to accommodate Anki-style power users with hundreds of due reviews;
|
|
13859
|
-
* apps targeting nimbler sessions should override via constructor.
|
|
13860
|
-
*/
|
|
13861
|
-
_initialReviewCap = 200;
|
|
13862
14042
|
sources;
|
|
13863
14043
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
13864
14044
|
_sessionRecord = [];
|
|
@@ -13867,10 +14047,28 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13867
14047
|
}
|
|
13868
14048
|
// Session card stores
|
|
13869
14049
|
_currentCard = null;
|
|
13870
|
-
|
|
13871
|
-
|
|
14050
|
+
/**
|
|
14051
|
+
* The single supply queue: `new` + `review` items interleaved in pipeline
|
|
14052
|
+
* rank order (the mixer's score-ordered, source-interleaved output, with
|
|
14053
|
+
* `+INF` required cards floated to the front). Drawn front-to-back; reviews
|
|
14054
|
+
* and new compete on one cross-comparable scale rather than being re-mixed
|
|
14055
|
+
* by a probability gate. Replaced/re-ranked wholesale on replan. See
|
|
14056
|
+
* `docs/decision-single-supply-queue.md`.
|
|
14057
|
+
*/
|
|
14058
|
+
supplyQ = new ItemQueue();
|
|
13872
14059
|
failedQ = new ItemQueue();
|
|
13873
14060
|
// END Session card stores
|
|
14061
|
+
/**
|
|
14062
|
+
* Supply draws since the last failed-queue *event* (a failed draw, or a card
|
|
14063
|
+
* entering failedQ on failure). Drives the light steady failed-interleave
|
|
14064
|
+
* (§7): after this many consecutive supply draws, a pending failed card is
|
|
14065
|
+
* drawn so remediation doesn't starve mid-session. Incremented on each supply
|
|
14066
|
+
* draw; reset to 0 both when a failed card is drawn AND when one is added to
|
|
14067
|
+
* failedQ — the latter gives a just-failed card spacing instead of an instant
|
|
14068
|
+
* retry (the counter would otherwise already be ≥ threshold from the preceding
|
|
14069
|
+
* supply run).
|
|
14070
|
+
*/
|
|
14071
|
+
_supplyDrawsSinceFailed = 0;
|
|
13874
14072
|
/**
|
|
13875
14073
|
* Promise tracking a currently in-progress replan, or null if idle.
|
|
13876
14074
|
* Used by nextCard() to await completion before drawing from queues.
|
|
@@ -13884,8 +14082,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13884
14082
|
*/
|
|
13885
14083
|
_activeReplanLabel = null;
|
|
13886
14084
|
/**
|
|
13887
|
-
* Number of well-indicated
|
|
13888
|
-
* degrades to poorly-indicated content. Decremented on each
|
|
14085
|
+
* Number of well-indicated supply cards remaining before the queue
|
|
14086
|
+
* degrades to poorly-indicated content. Decremented on each supplyQ
|
|
13889
14087
|
* draw; when it hits 0, a replan is triggered automatically
|
|
13890
14088
|
* (user state has changed from completing good cards).
|
|
13891
14089
|
*/
|
|
@@ -13894,7 +14092,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13894
14092
|
* When true, suppresses the quality-based auto-replan trigger in
|
|
13895
14093
|
* nextCard(). Set after a burst replan (small limit) to prevent the
|
|
13896
14094
|
* auto-replan from clobbering the burst cards before they're consumed.
|
|
13897
|
-
* Cleared when the depletion-triggered replan fires (
|
|
14095
|
+
* Cleared when the depletion-triggered replan fires (supplyQ exhausted).
|
|
13898
14096
|
*/
|
|
13899
14097
|
_suppressQualityReplan = false;
|
|
13900
14098
|
/**
|
|
@@ -13923,13 +14121,15 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13923
14121
|
* a draw the instant it happens — earlier than `_sessionRecord`, which only
|
|
13924
14122
|
* lands once the card is *responded to*.
|
|
13925
14123
|
*
|
|
13926
|
-
* Used to keep already-served cards out of
|
|
13927
|
-
* card shown once must never re-enter
|
|
13928
|
-
*
|
|
13929
|
-
*
|
|
13930
|
-
*
|
|
13931
|
-
*
|
|
13932
|
-
*
|
|
14124
|
+
* Used to keep already-served cards out of supplyQ on every (re)plan, across
|
|
14125
|
+
* ALL origins: a `new` card shown once must never re-enter, and once replans
|
|
14126
|
+
* re-pull reviews, an answered/in-flight review must not re-enter the supply
|
|
14127
|
+
* before its SRS reschedule clears the due-window (the review-loop guard,
|
|
14128
|
+
* decision doc §4). This is the general guard against re-presentation —
|
|
14129
|
+
* including the case where a replan in flight captured a now-drawn card (e.g.
|
|
14130
|
+
* a +INF require-injected follow-up the depletion prefetch grabbed just before
|
|
14131
|
+
* it was drawn). failedQ is separate and controller-owned, so failed cards
|
|
14132
|
+
* legitimately recur there without being gated here.
|
|
13933
14133
|
*/
|
|
13934
14134
|
_servedCardIds = /* @__PURE__ */ new Set();
|
|
13935
14135
|
/**
|
|
@@ -13954,14 +14154,12 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13954
14154
|
return this._minCardsGuarantee > 0;
|
|
13955
14155
|
}
|
|
13956
14156
|
get report() {
|
|
13957
|
-
const
|
|
13958
|
-
const
|
|
13959
|
-
|
|
13960
|
-
const newCardWord = newCount === 1 ? "new card" : "new cards";
|
|
13961
|
-
return `${reviewCount} ${reviewWord}, ${newCount} ${newCardWord}`;
|
|
14157
|
+
const supplyCount = this.supplyQ.dequeueCount;
|
|
14158
|
+
const supplyWord = supplyCount === 1 ? "card" : "cards";
|
|
14159
|
+
return `${supplyCount} supply ${supplyWord} drawn`;
|
|
13962
14160
|
}
|
|
13963
14161
|
get detailedReport() {
|
|
13964
|
-
return this.
|
|
14162
|
+
return this.supplyQ.toString + "\n" + this.failedQ.toString;
|
|
13965
14163
|
}
|
|
13966
14164
|
// @ts-expect-error NodeJS.Timeout type not available in browser context
|
|
13967
14165
|
_intervalHandle;
|
|
@@ -13972,11 +14170,9 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13972
14170
|
* @param getViewComponent - Function to resolve view components
|
|
13973
14171
|
* @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
|
|
13974
14172
|
* @param options - Optional session-level configuration
|
|
13975
|
-
* @param options.defaultBatchLimit - Default
|
|
14173
|
+
* @param options.defaultBatchLimit - Default supply working-set size (default: 20).
|
|
13976
14174
|
* Smaller values for newer users cause more frequent replans, keeping plans
|
|
13977
14175
|
* aligned with rapidly-changing user state.
|
|
13978
|
-
* @param options.initialReviewCap - Max reviews loaded at session start (default: 200).
|
|
13979
|
-
* Applied only on initial planning; replans do not refill the review queue.
|
|
13980
14176
|
*/
|
|
13981
14177
|
constructor(sources, time, dataLayer, getViewComponent, mixer, options) {
|
|
13982
14178
|
super();
|
|
@@ -13999,17 +14195,13 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13999
14195
|
if (options?.defaultBatchLimit !== void 0) {
|
|
14000
14196
|
this._defaultBatchLimit = options.defaultBatchLimit;
|
|
14001
14197
|
}
|
|
14002
|
-
if (options?.initialReviewCap !== void 0) {
|
|
14003
|
-
this._initialReviewCap = options.initialReviewCap;
|
|
14004
|
-
}
|
|
14005
14198
|
if (options?.outcomeObservers?.length) {
|
|
14006
14199
|
this._outcomeObservers = [...options.outcomeObservers];
|
|
14007
14200
|
}
|
|
14008
14201
|
this.log(`Session constructed:
|
|
14009
14202
|
startTime: ${this.startTime}
|
|
14010
14203
|
endTime: ${this.endTime}
|
|
14011
|
-
defaultBatchLimit: ${this._defaultBatchLimit}
|
|
14012
|
-
initialReviewCap: ${this._initialReviewCap}`);
|
|
14204
|
+
defaultBatchLimit: ${this._defaultBatchLimit}`);
|
|
14013
14205
|
registerActiveController(this);
|
|
14014
14206
|
}
|
|
14015
14207
|
tick() {
|
|
@@ -14043,15 +14235,6 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14043
14235
|
this.log(`Failed card cleanup estimate: ${Math.round(ret)}`);
|
|
14044
14236
|
return ret;
|
|
14045
14237
|
}
|
|
14046
|
-
/**
|
|
14047
|
-
* Extremely rough, conservative, estimate of amound of time to complete
|
|
14048
|
-
* all scheduled reviews
|
|
14049
|
-
*/
|
|
14050
|
-
estimateReviewTime() {
|
|
14051
|
-
const ret = 5 * this.reviewQ.length;
|
|
14052
|
-
this.log(`Review card time estimate: ${ret}`);
|
|
14053
|
-
return ret;
|
|
14054
|
-
}
|
|
14055
14238
|
async prepareSession() {
|
|
14056
14239
|
if (this.sources.some((s) => typeof s.getWeightedCards !== "function")) {
|
|
14057
14240
|
throw new Error(
|
|
@@ -14066,15 +14249,15 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14066
14249
|
);
|
|
14067
14250
|
}
|
|
14068
14251
|
await this.hydrationService.ensureHydratedCards();
|
|
14069
|
-
startSessionTracking(this.
|
|
14252
|
+
startSessionTracking(this.supplyQ.length, this.failedQ.length);
|
|
14070
14253
|
this._intervalHandle = setInterval(() => {
|
|
14071
14254
|
this.tick();
|
|
14072
14255
|
}, 1e3);
|
|
14073
14256
|
}
|
|
14074
14257
|
/**
|
|
14075
14258
|
* Request a mid-session replan. Re-runs the pipeline with current user state
|
|
14076
|
-
* and atomically replaces the
|
|
14077
|
-
* a session.
|
|
14259
|
+
* and atomically replaces (or merges into) the supplyQ contents. Safe to call
|
|
14260
|
+
* at any time during a session.
|
|
14078
14261
|
*
|
|
14079
14262
|
* Concurrency policy:
|
|
14080
14263
|
* - Two unhinted auto-replans never run in parallel; the second coalesces
|
|
@@ -14088,7 +14271,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14088
14271
|
* results (e.g. surfacing another gpc-intro card right after one
|
|
14089
14272
|
* completed, skipping the prescribed `c-wst-*` follow-up).
|
|
14090
14273
|
*
|
|
14091
|
-
*
|
|
14274
|
+
* Re-pulls and re-ranks the whole supply (including reviews); does NOT affect
|
|
14275
|
+
* failedQ (controller-owned remediation).
|
|
14092
14276
|
*
|
|
14093
14277
|
* If nextCard() is called while a replan is in flight, it will automatically
|
|
14094
14278
|
* await the replan before drawing from queues, ensuring the user always sees
|
|
@@ -14154,7 +14338,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14154
14338
|
* excludeCards happen at *invocation* time, not at *queue* time. For a
|
|
14155
14339
|
* queued replan that means excludes reflect the state after the prior
|
|
14156
14340
|
* replan landed — which is what we want, since the prior replan's
|
|
14157
|
-
*
|
|
14341
|
+
* supplyQ.peek(0) is the imminent draw we need to exclude.
|
|
14158
14342
|
*/
|
|
14159
14343
|
async _runReplan(opts) {
|
|
14160
14344
|
this._activeReplanLabel = opts.label ?? "(auto)";
|
|
@@ -14167,8 +14351,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14167
14351
|
for (const rec of this._sessionRecord) {
|
|
14168
14352
|
excludeSet.add(rec.card.card_id);
|
|
14169
14353
|
}
|
|
14170
|
-
if (this.
|
|
14171
|
-
excludeSet.add(this.
|
|
14354
|
+
if (this.supplyQ.length > 0) {
|
|
14355
|
+
excludeSet.add(this.supplyQ.peek(0).cardID);
|
|
14172
14356
|
}
|
|
14173
14357
|
hints.excludeCards = [...excludeSet];
|
|
14174
14358
|
if (opts.sessionHints !== void 0) {
|
|
@@ -14229,7 +14413,13 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14229
14413
|
const describe = (q) => {
|
|
14230
14414
|
const cards = [];
|
|
14231
14415
|
for (let i = 0; i < q.length; i++) {
|
|
14232
|
-
|
|
14416
|
+
const item = q.peek(i);
|
|
14417
|
+
cards.push({
|
|
14418
|
+
cardID: item.cardID,
|
|
14419
|
+
status: item.status,
|
|
14420
|
+
origin: isReview(item) ? "review" : "new",
|
|
14421
|
+
score: item.score
|
|
14422
|
+
});
|
|
14233
14423
|
}
|
|
14234
14424
|
return { length: q.length, dequeueCount: q.dequeueCount, cards };
|
|
14235
14425
|
};
|
|
@@ -14252,9 +14442,9 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14252
14442
|
sessionHints: this._sessionHints,
|
|
14253
14443
|
replanActive: this._replanPromise !== null,
|
|
14254
14444
|
replanLabel: this._activeReplanLabel,
|
|
14255
|
-
|
|
14256
|
-
newQ: describe(this.newQ),
|
|
14445
|
+
supplyQ: describe(this.supplyQ),
|
|
14257
14446
|
failedQ: describe(this.failedQ),
|
|
14447
|
+
reviewBacklog: getSrsBacklogDebug(),
|
|
14258
14448
|
drawnCards
|
|
14259
14449
|
};
|
|
14260
14450
|
}
|
|
@@ -14374,7 +14564,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14374
14564
|
*/
|
|
14375
14565
|
static WELL_INDICATED_SCORE = 0.1;
|
|
14376
14566
|
/**
|
|
14377
|
-
*
|
|
14567
|
+
* supplyQ length at or below which the opportunistic depletion-prefetch
|
|
14378
14568
|
* fires. Sets the lead time available for the background replan to land
|
|
14379
14569
|
* before the user actually empties the queue and falls into the
|
|
14380
14570
|
* (synchronous) wedge-breaker path.
|
|
@@ -14387,7 +14577,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14387
14577
|
*/
|
|
14388
14578
|
static DEPLETION_PREFETCH_THRESHOLD = 3;
|
|
14389
14579
|
/**
|
|
14390
|
-
* Internal replan execution. Runs the pipeline,
|
|
14580
|
+
* Internal replan execution. Runs the pipeline, rebuilds the supplyQ,
|
|
14391
14581
|
* atomically swaps it in, and triggers hydration for the new contents.
|
|
14392
14582
|
*
|
|
14393
14583
|
* If the initial replan produces fewer than MIN_WELL_INDICATED cards that
|
|
@@ -14416,8 +14606,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14416
14606
|
}
|
|
14417
14607
|
await this.hydrationService.ensureHydratedCards();
|
|
14418
14608
|
const labelTag = opts.label ? ` [${opts.label}]` : "";
|
|
14419
|
-
this.log(`Replan complete${labelTag}:
|
|
14420
|
-
snapshotQueues(this.
|
|
14609
|
+
this.log(`Replan complete${labelTag}: supplyQ now has ${this.supplyQ.length} cards (mode=${mode})`);
|
|
14610
|
+
snapshotQueues(this.supplyQ.length, this.failedQ.length);
|
|
14421
14611
|
}
|
|
14422
14612
|
addTime(seconds) {
|
|
14423
14613
|
this.endTime = new Date(this.endTime.valueOf() + 1e3 * seconds);
|
|
@@ -14426,10 +14616,10 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14426
14616
|
return this.failedQ.length;
|
|
14427
14617
|
}
|
|
14428
14618
|
toString() {
|
|
14429
|
-
return `Session: ${this.
|
|
14619
|
+
return `Session: ${this.supplyQ.length} supply, ${this.failedQ.length} failed`;
|
|
14430
14620
|
}
|
|
14431
14621
|
reportString() {
|
|
14432
|
-
return `${this.
|
|
14622
|
+
return `${this.supplyQ.dequeueCount} supply, ${this.failedQ.dequeueCount} failed`;
|
|
14433
14623
|
}
|
|
14434
14624
|
/**
|
|
14435
14625
|
* Returns debug information about the current session state.
|
|
@@ -14446,7 +14636,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14446
14636
|
items.push({
|
|
14447
14637
|
courseID: item.courseID || "unknown",
|
|
14448
14638
|
cardID: item.cardID || "unknown",
|
|
14449
|
-
status: item.status || "unknown"
|
|
14639
|
+
status: item.status || "unknown",
|
|
14640
|
+
score: item.score
|
|
14450
14641
|
});
|
|
14451
14642
|
}
|
|
14452
14643
|
return items;
|
|
@@ -14456,15 +14647,10 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14456
14647
|
mode: supportsWeightedCards ? "weighted" : "legacy",
|
|
14457
14648
|
description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "ERROR: getWeightedCards() not a function."
|
|
14458
14649
|
},
|
|
14459
|
-
|
|
14460
|
-
length: this.
|
|
14461
|
-
dequeueCount: this.
|
|
14462
|
-
items: extractQueueItems(this.
|
|
14463
|
-
},
|
|
14464
|
-
newQueue: {
|
|
14465
|
-
length: this.newQ.length,
|
|
14466
|
-
dequeueCount: this.newQ.dequeueCount,
|
|
14467
|
-
items: extractQueueItems(this.newQ)
|
|
14650
|
+
supplyQueue: {
|
|
14651
|
+
length: this.supplyQ.length,
|
|
14652
|
+
dequeueCount: this.supplyQ.dequeueCount,
|
|
14653
|
+
items: extractQueueItems(this.supplyQ)
|
|
14468
14654
|
},
|
|
14469
14655
|
failedQueue: {
|
|
14470
14656
|
length: this.failedQ.length,
|
|
@@ -14484,30 +14670,29 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14484
14670
|
};
|
|
14485
14671
|
}
|
|
14486
14672
|
/**
|
|
14487
|
-
* Fetch content
|
|
14673
|
+
* Fetch weighted content from all sources, mix across sources, and populate
|
|
14674
|
+
* the single supply queue in pipeline rank order.
|
|
14488
14675
|
*
|
|
14489
|
-
*
|
|
14490
|
-
* 1.
|
|
14491
|
-
*
|
|
14492
|
-
*
|
|
14493
|
-
*
|
|
14494
|
-
|
|
14495
|
-
/**
|
|
14496
|
-
* Fetch weighted content from all sources and populate session queues.
|
|
14676
|
+
* Reviews and new cards compete on one cross-comparable scale (SRS 0.5–1.0
|
|
14677
|
+
* w/ backlog pressure vs ELO 0.0–1.0) — there is no origin split and no
|
|
14678
|
+
* second mixer. The working set is `supplyLimit` cards (the top of the mixed
|
|
14679
|
+
* ranking, plus any `+INF` required cards floated to the front); replans
|
|
14680
|
+
* re-pull and re-rank the whole supply, so a heavy review backlog surfaces as
|
|
14681
|
+
* a refreshed top-ranked working set rather than a frozen 200-card snapshot.
|
|
14497
14682
|
*
|
|
14498
14683
|
* @param options.replan - If true, this is a mid-session replan rather than
|
|
14499
|
-
* initial session setup.
|
|
14500
|
-
*
|
|
14501
|
-
* @param options.additive - If true (replan only), merge
|
|
14502
|
-
* candidates into the front of the existing
|
|
14684
|
+
* initial session setup. Atomically replaces supplyQ contents and treats
|
|
14685
|
+
* empty results as non-fatal.
|
|
14686
|
+
* @param options.additive - If true (replan only), merge high-quality
|
|
14687
|
+
* candidates into the front of the existing supplyQ instead of replacing it.
|
|
14503
14688
|
* @returns Number of "well-indicated" cards (passed all hierarchy filters)
|
|
14504
14689
|
* in the new content. Returns -1 if no content was loaded.
|
|
14505
14690
|
*/
|
|
14506
14691
|
async getWeightedContent(options) {
|
|
14507
14692
|
const replan = options?.replan ?? false;
|
|
14508
14693
|
const additive = options?.additive ?? false;
|
|
14509
|
-
const
|
|
14510
|
-
const fetchLimit =
|
|
14694
|
+
const supplyLimit = options?.limit ?? this._defaultBatchLimit;
|
|
14695
|
+
const fetchLimit = supplyLimit;
|
|
14511
14696
|
if (!replan) {
|
|
14512
14697
|
this._applyHintsToSources();
|
|
14513
14698
|
}
|
|
@@ -14529,7 +14714,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14529
14714
|
}
|
|
14530
14715
|
if (batches.length === 0) {
|
|
14531
14716
|
if (replan) {
|
|
14532
|
-
this.log("Replan: no content from any source, keeping existing
|
|
14717
|
+
this.log("Replan: no content from any source, keeping existing supplyQ");
|
|
14533
14718
|
return -1;
|
|
14534
14719
|
}
|
|
14535
14720
|
throw new Error(
|
|
@@ -14563,64 +14748,59 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14563
14748
|
quotaPerSource,
|
|
14564
14749
|
mixedWeighted
|
|
14565
14750
|
);
|
|
14566
|
-
const
|
|
14567
|
-
const
|
|
14568
|
-
|
|
14569
|
-
|
|
14570
|
-
const mandatoryWeighted = newCandidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
|
|
14571
|
-
const optionalWeighted = newCandidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
|
|
14572
|
-
const newWeighted = [
|
|
14751
|
+
const candidates = mixedWeighted.filter((w) => !this._servedCardIds.has(w.cardId));
|
|
14752
|
+
const mandatoryWeighted = candidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
|
|
14753
|
+
const optionalWeighted = candidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
|
|
14754
|
+
const supplyWeighted = [
|
|
14573
14755
|
...mandatoryWeighted,
|
|
14574
|
-
...optionalWeighted.slice(0, Math.max(0,
|
|
14756
|
+
...optionalWeighted.slice(0, Math.max(0, supplyLimit - mandatoryWeighted.length))
|
|
14575
14757
|
];
|
|
14576
14758
|
const mandatoryIds = new Set(mandatoryWeighted.map((w) => w.cardId));
|
|
14577
|
-
|
|
14578
|
-
let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
|
|
14579
|
-
if (!replan) {
|
|
14580
|
-
for (const w of reviewWeighted) {
|
|
14581
|
-
const reviewItem = {
|
|
14582
|
-
cardID: w.cardId,
|
|
14583
|
-
courseID: w.courseId,
|
|
14584
|
-
contentSourceType: "course",
|
|
14585
|
-
contentSourceID: w.courseId,
|
|
14586
|
-
reviewID: w.reviewID,
|
|
14587
|
-
status: "review"
|
|
14588
|
-
};
|
|
14589
|
-
this.reviewQ.add(reviewItem, reviewItem.cardID);
|
|
14590
|
-
report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
14591
|
-
`;
|
|
14592
|
-
}
|
|
14593
|
-
}
|
|
14594
|
-
const wellIndicated = newWeighted.filter(
|
|
14759
|
+
const wellIndicated = supplyWeighted.filter(
|
|
14595
14760
|
(w) => w.score >= _SessionController.WELL_INDICATED_SCORE
|
|
14596
14761
|
).length;
|
|
14597
|
-
|
|
14598
|
-
|
|
14599
|
-
const
|
|
14600
|
-
|
|
14601
|
-
|
|
14602
|
-
contentSourceType: "course",
|
|
14603
|
-
contentSourceID: w.courseId,
|
|
14604
|
-
status: "new"
|
|
14605
|
-
};
|
|
14606
|
-
newItems.push(newItem);
|
|
14607
|
-
report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
14762
|
+
let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
|
|
14763
|
+
const supplyItems = supplyWeighted.map((w) => {
|
|
14764
|
+
const origin = getCardOrigin(w);
|
|
14765
|
+
const scoreStr = Number.isFinite(w.score) ? w.score.toFixed(2) : "+INF";
|
|
14766
|
+
report += `${origin === "review" ? "Review" : "New"}: ${w.courseId}::${w.cardId} (score: ${scoreStr})
|
|
14608
14767
|
`;
|
|
14609
|
-
|
|
14768
|
+
return this._buildSupplyItem(w, origin);
|
|
14769
|
+
});
|
|
14610
14770
|
if (additive) {
|
|
14611
|
-
const added = this.
|
|
14612
|
-
report += `Additive merge: ${added}
|
|
14771
|
+
const added = this.supplyQ.mergeToFront(supplyItems, (item) => item.cardID, mandatoryIds);
|
|
14772
|
+
report += `Additive merge: ${added} cards added to front of supplyQ
|
|
14613
14773
|
`;
|
|
14614
14774
|
} else if (replan) {
|
|
14615
|
-
this.
|
|
14775
|
+
this.supplyQ.replaceAll(supplyItems, (item) => item.cardID);
|
|
14616
14776
|
} else {
|
|
14617
|
-
for (const item of
|
|
14618
|
-
this.
|
|
14777
|
+
for (const item of supplyItems) {
|
|
14778
|
+
this.supplyQ.add(item, item.cardID);
|
|
14619
14779
|
}
|
|
14620
14780
|
}
|
|
14621
14781
|
this.log(report);
|
|
14622
14782
|
return wellIndicated;
|
|
14623
14783
|
}
|
|
14784
|
+
/**
|
|
14785
|
+
* Build a supply item from a weighted candidate. Review-origin cards carry
|
|
14786
|
+
* their `reviewID` so SRS outcome tracking and re-presentation work; new
|
|
14787
|
+
* cards do not. `score` is carried on both for the debug overlay.
|
|
14788
|
+
*/
|
|
14789
|
+
_buildSupplyItem(w, origin = getCardOrigin(w)) {
|
|
14790
|
+
const base = {
|
|
14791
|
+
cardID: w.cardId,
|
|
14792
|
+
courseID: w.courseId,
|
|
14793
|
+
contentSourceType: "course",
|
|
14794
|
+
contentSourceID: w.courseId,
|
|
14795
|
+
score: w.score
|
|
14796
|
+
};
|
|
14797
|
+
if (origin === "review") {
|
|
14798
|
+
const reviewItem = { ...base, status: "review", reviewID: w.reviewID };
|
|
14799
|
+
return reviewItem;
|
|
14800
|
+
}
|
|
14801
|
+
const newItem = { ...base, status: "new" };
|
|
14802
|
+
return newItem;
|
|
14803
|
+
}
|
|
14624
14804
|
/**
|
|
14625
14805
|
* Returns items that should be pre-hydrated.
|
|
14626
14806
|
* Deterministic: top N items from each queue to ensure coverage.
|
|
@@ -14628,71 +14808,73 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14628
14808
|
*/
|
|
14629
14809
|
_getItemsToHydrate() {
|
|
14630
14810
|
const items = [];
|
|
14631
|
-
const
|
|
14632
|
-
|
|
14633
|
-
|
|
14634
|
-
|
|
14635
|
-
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.newQ.length); i++) {
|
|
14636
|
-
items.push(this.newQ.peek(i));
|
|
14811
|
+
const SUPPLY_PREFETCH = 3;
|
|
14812
|
+
const FAILED_PREFETCH = 2;
|
|
14813
|
+
for (let i = 0; i < Math.min(SUPPLY_PREFETCH, this.supplyQ.length); i++) {
|
|
14814
|
+
items.push(this.supplyQ.peek(i));
|
|
14637
14815
|
}
|
|
14638
|
-
for (let i = 0; i < Math.min(
|
|
14816
|
+
for (let i = 0; i < Math.min(FAILED_PREFETCH, this.failedQ.length); i++) {
|
|
14639
14817
|
items.push(this.failedQ.peek(i));
|
|
14640
14818
|
}
|
|
14641
14819
|
return items;
|
|
14642
14820
|
}
|
|
14643
14821
|
/**
|
|
14644
14822
|
* Selects the next item to present to the user.
|
|
14645
|
-
*
|
|
14823
|
+
*
|
|
14824
|
+
* The supplyQ is already rank-ordered (the pipeline + mixer did the mixing,
|
|
14825
|
+
* with `+INF` required cards floated to the front), so the primary path is a
|
|
14826
|
+
* deterministic front-to-back draw — no second new-vs-review mixer. The only
|
|
14827
|
+
* remaining decisions are (a) when the session ends and (b) when to interleave
|
|
14828
|
+
* a remediation card from failedQ. See decision doc §2/§3/§7.
|
|
14646
14829
|
*/
|
|
14647
14830
|
_selectNextItemToHydrate() {
|
|
14648
|
-
|
|
14649
|
-
let newBound = 0.1;
|
|
14650
|
-
let reviewBound = 0.75;
|
|
14651
|
-
if (this.reviewQ.length === 0 && this.failedQ.length === 0 && this.newQ.length === 0) {
|
|
14831
|
+
if (this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
14652
14832
|
return null;
|
|
14653
14833
|
}
|
|
14654
14834
|
if (this._secondsRemaining < 2 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
|
|
14655
14835
|
return null;
|
|
14656
14836
|
}
|
|
14657
14837
|
if (this._secondsRemaining <= 0 && this._minCardsGuarantee <= 0) {
|
|
14658
|
-
|
|
14659
|
-
return this.failedQ.peek(0);
|
|
14660
|
-
} else {
|
|
14661
|
-
return null;
|
|
14662
|
-
}
|
|
14838
|
+
return this.failedQ.length > 0 ? this.failedQ.peek(0) : null;
|
|
14663
14839
|
}
|
|
14664
|
-
|
|
14665
|
-
|
|
14840
|
+
const supplyTop = this.supplyQ.length > 0 ? this.supplyQ.peek(0) : null;
|
|
14841
|
+
if (this._minCardsGuarantee > 0 && supplyTop) {
|
|
14842
|
+
return supplyTop;
|
|
14666
14843
|
}
|
|
14667
|
-
|
|
14668
|
-
|
|
14669
|
-
const availableTime = this._secondsRemaining - (cleanupTime + reviewTime);
|
|
14670
|
-
if (availableTime > 20) {
|
|
14671
|
-
newBound = 0.5;
|
|
14672
|
-
reviewBound = 0.9;
|
|
14673
|
-
} else if (this._secondsRemaining - cleanupTime > 20) {
|
|
14674
|
-
newBound = 0.05;
|
|
14675
|
-
reviewBound = 0.9;
|
|
14676
|
-
} else {
|
|
14677
|
-
newBound = 0.01;
|
|
14678
|
-
reviewBound = 0.1;
|
|
14679
|
-
}
|
|
14680
|
-
if (this.failedQ.length === 0) {
|
|
14681
|
-
reviewBound = 1;
|
|
14844
|
+
if (this.failedQ.length > 0 && this._shouldInterleaveFailed(supplyTop !== null)) {
|
|
14845
|
+
return this.failedQ.peek(0);
|
|
14682
14846
|
}
|
|
14683
|
-
if (
|
|
14684
|
-
|
|
14847
|
+
if (supplyTop) {
|
|
14848
|
+
return supplyTop;
|
|
14685
14849
|
}
|
|
14686
|
-
if (
|
|
14687
|
-
return this.newQ.peek(0);
|
|
14688
|
-
} else if (choice < reviewBound && this.reviewQ.length) {
|
|
14689
|
-
return this.reviewQ.peek(0);
|
|
14690
|
-
} else if (this.failedQ.length) {
|
|
14850
|
+
if (this.failedQ.length > 0) {
|
|
14691
14851
|
return this.failedQ.peek(0);
|
|
14692
|
-
} else {
|
|
14693
|
-
this.log(`No more cards available for the session!`);
|
|
14694
|
-
return null;
|
|
14695
14852
|
}
|
|
14853
|
+
this.log(`No more cards available for the session!`);
|
|
14854
|
+
return null;
|
|
14855
|
+
}
|
|
14856
|
+
/** Supply draws between forced failed-queue interleaves (light steady cadence). */
|
|
14857
|
+
static FAILED_INTERLEAVE_EVERY = 4;
|
|
14858
|
+
/**
|
|
14859
|
+
* Slack (seconds) below which the endgame failed-pressure kicks in: when the
|
|
14860
|
+
* time left after clearing remediation drops under this, bias hard to failed
|
|
14861
|
+
* so the session doesn't end with un-cleared remediation. Mirrors the old
|
|
14862
|
+
* `availableTime > 20` ladder thresholds.
|
|
14863
|
+
*/
|
|
14864
|
+
static FAILED_ENDGAME_SLACK_SECONDS = 20;
|
|
14865
|
+
/**
|
|
14866
|
+
* Whether to interleave a failed (remediation) card now instead of drawing
|
|
14867
|
+
* the supply head. Replaces the old `newBound`/`reviewBound` probability
|
|
14868
|
+
* ladder's failed path (decision doc §7).
|
|
14869
|
+
*
|
|
14870
|
+
* @param supplyAvailable - whether supplyQ has a card to draw instead.
|
|
14871
|
+
*/
|
|
14872
|
+
_shouldInterleaveFailed(supplyAvailable) {
|
|
14873
|
+
if (this.failedQ.length === 0) return false;
|
|
14874
|
+
if (!supplyAvailable) return true;
|
|
14875
|
+
const availableTime = this._secondsRemaining - this.estimateCleanupTime();
|
|
14876
|
+
if (availableTime <= _SessionController.FAILED_ENDGAME_SLACK_SECONDS) return true;
|
|
14877
|
+
return this._supplyDrawsSinceFailed >= _SessionController.FAILED_INTERLEAVE_EVERY;
|
|
14696
14878
|
}
|
|
14697
14879
|
async nextCard(action = "dismiss-success") {
|
|
14698
14880
|
this.dismissCurrentCard(action);
|
|
@@ -14700,22 +14882,21 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14700
14882
|
this._minCardsGuarantee--;
|
|
14701
14883
|
this.log(`[CardGuarantee] ${this._minCardsGuarantee} guaranteed cards remaining`);
|
|
14702
14884
|
}
|
|
14703
|
-
if (this._replanPromise && this.
|
|
14885
|
+
if (this._replanPromise && this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
14704
14886
|
this.log("nextCard: queues empty, awaiting in-flight replan before drawing");
|
|
14705
14887
|
await this._replanPromise;
|
|
14706
14888
|
}
|
|
14707
|
-
if (this.
|
|
14889
|
+
if (this.supplyQ.length <= _SessionController.DEPLETION_PREFETCH_THRESHOLD && this._secondsRemaining > 0 && !this._replanPromise) {
|
|
14708
14890
|
this._suppressQualityReplan = false;
|
|
14709
|
-
const otherContent = this.reviewQ.length + this.failedQ.length;
|
|
14710
14891
|
this.log(
|
|
14711
|
-
`[AutoReplan:depletion]
|
|
14892
|
+
`[AutoReplan:depletion] supplyQ has ${this.supplyQ.length} card(s) (${this.failedQ.length} failed pending) with ${this._secondsRemaining}s remaining. Triggering background replan.`
|
|
14712
14893
|
);
|
|
14713
14894
|
void this.requestReplan({ label: "auto:depletion", mode: "merge" });
|
|
14714
14895
|
}
|
|
14715
14896
|
const REPLAN_BUFFER = 3;
|
|
14716
|
-
if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.
|
|
14897
|
+
if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.supplyQ.length > 0 && !this._replanPromise) {
|
|
14717
14898
|
this.log(
|
|
14718
|
-
`[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining (
|
|
14899
|
+
`[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining (supplyQ: ${this.supplyQ.length}). Triggering background replan.`
|
|
14719
14900
|
);
|
|
14720
14901
|
void this.requestReplan({ label: "auto:quality" });
|
|
14721
14902
|
}
|
|
@@ -14727,12 +14908,12 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14727
14908
|
const WEDGE_MAX_EMPTY_STREAK = 3;
|
|
14728
14909
|
const WEDGE_BACKOFF_MS = 250;
|
|
14729
14910
|
let wedgeEmptyStreak = 0;
|
|
14730
|
-
while (this._secondsRemaining > 0 && this.
|
|
14911
|
+
while (this._secondsRemaining > 0 && this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
14731
14912
|
this.log(
|
|
14732
14913
|
`[WedgeBreaker] All queues empty with ${this._secondsRemaining}s remaining. Running pipeline (attempt ${wedgeEmptyStreak + 1}/${WEDGE_MAX_EMPTY_STREAK}).`
|
|
14733
14914
|
);
|
|
14734
14915
|
await this._replanUncoalesced({ label: "wedge-breaker" });
|
|
14735
|
-
if (this.
|
|
14916
|
+
if (this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
14736
14917
|
wedgeEmptyStreak++;
|
|
14737
14918
|
if (wedgeEmptyStreak >= WEDGE_MAX_EMPTY_STREAK) {
|
|
14738
14919
|
this.log(
|
|
@@ -14762,15 +14943,16 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14762
14943
|
await this.hydrationService.ensureHydratedCards();
|
|
14763
14944
|
this._currentCard = card;
|
|
14764
14945
|
const origin = nextItem.status === "review" || nextItem.status === "failed-review" ? "review" : nextItem.status === "new" || nextItem.status === "failed-new" ? "new" : "failed";
|
|
14765
|
-
const queueSource = nextItem.status.startsWith("failed") ? "failedQ" :
|
|
14946
|
+
const queueSource = nextItem.status.startsWith("failed") ? "failedQ" : "supplyQ";
|
|
14766
14947
|
recordCardPresentation(
|
|
14767
14948
|
nextItem.cardID,
|
|
14768
14949
|
nextItem.courseID,
|
|
14769
14950
|
this.courseNameCache.get(nextItem.courseID),
|
|
14770
14951
|
origin,
|
|
14771
|
-
queueSource
|
|
14952
|
+
queueSource,
|
|
14953
|
+
nextItem.score
|
|
14772
14954
|
);
|
|
14773
|
-
snapshotQueues(this.
|
|
14955
|
+
snapshotQueues(this.supplyQ.length, this.failedQ.length);
|
|
14774
14956
|
return card;
|
|
14775
14957
|
}
|
|
14776
14958
|
this.log(`Skipping card ${nextItem.cardID}: hydration failed, trying next`);
|
|
@@ -14840,6 +15022,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14840
15022
|
};
|
|
14841
15023
|
}
|
|
14842
15024
|
this.failedQ.add(failedItem, failedItem.cardID);
|
|
15025
|
+
this._supplyDrawsSinceFailed = 0;
|
|
14843
15026
|
} else if (action === "dismiss-error") {
|
|
14844
15027
|
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
14845
15028
|
} else if (action === "dismiss-failed") {
|
|
@@ -14853,15 +15036,15 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14853
15036
|
removeItemFromQueue(item) {
|
|
14854
15037
|
this._clearDurableRequirement(item.cardID);
|
|
14855
15038
|
this._servedCardIds.add(item.cardID);
|
|
14856
|
-
if (this.
|
|
14857
|
-
this.
|
|
14858
|
-
|
|
14859
|
-
|
|
15039
|
+
if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
15040
|
+
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
15041
|
+
this._supplyDrawsSinceFailed = 0;
|
|
15042
|
+
} else if (this.supplyQ.peek(0)?.cardID === item.cardID) {
|
|
15043
|
+
this.supplyQ.dequeue((queueItem) => queueItem.cardID);
|
|
15044
|
+
this._supplyDrawsSinceFailed++;
|
|
14860
15045
|
if (this._wellIndicatedRemaining > 0) {
|
|
14861
15046
|
this._wellIndicatedRemaining--;
|
|
14862
15047
|
}
|
|
14863
|
-
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
14864
|
-
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
14865
15048
|
}
|
|
14866
15049
|
}
|
|
14867
15050
|
/**
|
|
@@ -14960,6 +15143,7 @@ export {
|
|
|
14960
15143
|
areQuestionRecords,
|
|
14961
15144
|
buildStrategyStateId,
|
|
14962
15145
|
captureMixerRun,
|
|
15146
|
+
clearSrsBacklogDebug,
|
|
14963
15147
|
computeDeviation,
|
|
14964
15148
|
computeEffectiveWeight,
|
|
14965
15149
|
computeOutcomeSignal,
|
|
@@ -14970,6 +15154,7 @@ export {
|
|
|
14970
15154
|
docIsDeleted,
|
|
14971
15155
|
endSessionTracking,
|
|
14972
15156
|
ensureAppDataDirectory,
|
|
15157
|
+
getActivePipeline,
|
|
14973
15158
|
getAppDataDirectory,
|
|
14974
15159
|
getCardHistoryID,
|
|
14975
15160
|
getCardOrigin,
|
|
@@ -14979,6 +15164,7 @@ export {
|
|
|
14979
15164
|
getRegisteredNavigator,
|
|
14980
15165
|
getRegisteredNavigatorNames,
|
|
14981
15166
|
getRegisteredNavigatorRole,
|
|
15167
|
+
getSrsBacklogDebug,
|
|
14982
15168
|
getStudySource,
|
|
14983
15169
|
hasRegisteredNavigator,
|
|
14984
15170
|
importParsedCards,
|