@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.js
CHANGED
|
@@ -967,6 +967,7 @@ __export(PipelineDebugger_exports, {
|
|
|
967
967
|
buildRunReport: () => buildRunReport,
|
|
968
968
|
captureRun: () => captureRun,
|
|
969
969
|
clearRunHistory: () => clearRunHistory,
|
|
970
|
+
getActivePipeline: () => getActivePipeline,
|
|
970
971
|
mountPipelineDebugger: () => mountPipelineDebugger,
|
|
971
972
|
pipelineDebugAPI: () => pipelineDebugAPI,
|
|
972
973
|
registerPipelineForDebug: () => registerPipelineForDebug
|
|
@@ -974,6 +975,9 @@ __export(PipelineDebugger_exports, {
|
|
|
974
975
|
function registerPipelineForDebug(pipeline) {
|
|
975
976
|
_activePipeline = pipeline;
|
|
976
977
|
}
|
|
978
|
+
function getActivePipeline() {
|
|
979
|
+
return _activePipeline;
|
|
980
|
+
}
|
|
977
981
|
function clearRunHistory() {
|
|
978
982
|
runHistory.length = 0;
|
|
979
983
|
}
|
|
@@ -1789,6 +1793,30 @@ Example:
|
|
|
1789
1793
|
}
|
|
1790
1794
|
});
|
|
1791
1795
|
|
|
1796
|
+
// src/core/navigators/SrsDebugger.ts
|
|
1797
|
+
var SrsDebugger_exports = {};
|
|
1798
|
+
__export(SrsDebugger_exports, {
|
|
1799
|
+
captureSrsBacklog: () => captureSrsBacklog,
|
|
1800
|
+
clearSrsBacklogDebug: () => clearSrsBacklogDebug,
|
|
1801
|
+
getSrsBacklogDebug: () => getSrsBacklogDebug
|
|
1802
|
+
});
|
|
1803
|
+
function captureSrsBacklog(snapshot) {
|
|
1804
|
+
snapshots.set(snapshot.courseId, snapshot);
|
|
1805
|
+
}
|
|
1806
|
+
function getSrsBacklogDebug() {
|
|
1807
|
+
return [...snapshots.values()].sort((a, b) => b.timestamp - a.timestamp);
|
|
1808
|
+
}
|
|
1809
|
+
function clearSrsBacklogDebug() {
|
|
1810
|
+
snapshots.clear();
|
|
1811
|
+
}
|
|
1812
|
+
var snapshots;
|
|
1813
|
+
var init_SrsDebugger = __esm({
|
|
1814
|
+
"src/core/navigators/SrsDebugger.ts"() {
|
|
1815
|
+
"use strict";
|
|
1816
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
1817
|
+
}
|
|
1818
|
+
});
|
|
1819
|
+
|
|
1792
1820
|
// src/core/navigators/generators/CompositeGenerator.ts
|
|
1793
1821
|
var CompositeGenerator_exports = {};
|
|
1794
1822
|
__export(CompositeGenerator_exports, {
|
|
@@ -2156,7 +2184,7 @@ function shuffleInPlace(arr) {
|
|
|
2156
2184
|
function pickTopByScore(cards, limit) {
|
|
2157
2185
|
return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
|
|
2158
2186
|
}
|
|
2159
|
-
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;
|
|
2187
|
+
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;
|
|
2160
2188
|
var init_prescribed = __esm({
|
|
2161
2189
|
"src/core/navigators/generators/prescribed.ts"() {
|
|
2162
2190
|
"use strict";
|
|
@@ -2173,6 +2201,9 @@ var init_prescribed = __esm({
|
|
|
2173
2201
|
BASE_SUPPORT_SCORE = 0.8;
|
|
2174
2202
|
DISCOVERED_SUPPORT_SCORE = 12;
|
|
2175
2203
|
BASE_PRACTICE_SCORE = 1;
|
|
2204
|
+
PRACTICE_BASE_MULT = 2;
|
|
2205
|
+
MAX_PRACTICE_MULTIPLIER = 4;
|
|
2206
|
+
PRACTICE_STALENESS_BUMP_PER_DAY = 0.5;
|
|
2176
2207
|
MAX_TARGET_MULTIPLIER = 8;
|
|
2177
2208
|
MAX_SUPPORT_MULTIPLIER = 4;
|
|
2178
2209
|
PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
|
|
@@ -2232,6 +2263,8 @@ var init_prescribed = __esm({
|
|
|
2232
2263
|
const emitted = [];
|
|
2233
2264
|
const emittedIds = /* @__PURE__ */ new Set();
|
|
2234
2265
|
const groupRuntimes = [];
|
|
2266
|
+
const priorPracticeDebt = progress.practiceDebt ?? {};
|
|
2267
|
+
const nextPracticeDebt = {};
|
|
2235
2268
|
for (const group of this.config.groups) {
|
|
2236
2269
|
const runtime = this.buildGroupRuntimeState({
|
|
2237
2270
|
group,
|
|
@@ -2289,10 +2322,13 @@ var init_prescribed = __esm({
|
|
|
2289
2322
|
userTagElo,
|
|
2290
2323
|
userGlobalElo,
|
|
2291
2324
|
activeIds,
|
|
2292
|
-
seenIds
|
|
2325
|
+
seenIds,
|
|
2326
|
+
priorPracticeDebt,
|
|
2327
|
+
nextPracticeDebt
|
|
2293
2328
|
});
|
|
2294
2329
|
emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
|
|
2295
2330
|
}
|
|
2331
|
+
nextState.practiceDebt = nextPracticeDebt;
|
|
2296
2332
|
const hintSummary = this.buildSupportHintSummary(groupRuntimes);
|
|
2297
2333
|
const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
|
|
2298
2334
|
boostTags: hintSummary.boostTags,
|
|
@@ -2626,9 +2662,16 @@ var init_prescribed = __esm({
|
|
|
2626
2662
|
* `practiceMinCount`), this resolves cards carrying that tag and emits them
|
|
2627
2663
|
* into the candidate pool. It exists because global-ELO retrieval
|
|
2628
2664
|
* systematically fails to fetch the (low-ELO) drill cards for a
|
|
2629
|
-
* freshly-introduced skill — putting them in the pool here
|
|
2630
|
-
*
|
|
2631
|
-
*
|
|
2665
|
+
* freshly-introduced skill — putting them in the pool here guarantees presence.
|
|
2666
|
+
*
|
|
2667
|
+
* Emphasis is a **practice-debt pressure** (parallel to SRS backlog pressure):
|
|
2668
|
+
* cards score `base × multiplier`, where the multiplier starts at
|
|
2669
|
+
* PRACTICE_BASE_MULT (so a few reps land promptly post-intro, competing with
|
|
2670
|
+
* pressured reviews) and escalates by how long the debt has stayed open
|
|
2671
|
+
* (per-tag, time-based via `priorPracticeDebt`/`nextPracticeDebt`), clamped at
|
|
2672
|
+
* MAX_PRACTICE_MULTIPLIER. The debt is durable and self-discharges the instant
|
|
2673
|
+
* the skill reaches `practiceMinCount` — so this no longer relies on the
|
|
2674
|
+
* session-scoped intro boost to actually surface.
|
|
2632
2675
|
*
|
|
2633
2676
|
* Fully data-driven: the unlock relation comes from the hierarchy config and
|
|
2634
2677
|
* practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
|
|
@@ -2643,7 +2686,9 @@ var init_prescribed = __esm({
|
|
|
2643
2686
|
userTagElo,
|
|
2644
2687
|
userGlobalElo,
|
|
2645
2688
|
activeIds,
|
|
2646
|
-
seenIds
|
|
2689
|
+
seenIds,
|
|
2690
|
+
priorPracticeDebt,
|
|
2691
|
+
nextPracticeDebt
|
|
2647
2692
|
} = args;
|
|
2648
2693
|
const patterns = group.practiceTagPatterns ?? [];
|
|
2649
2694
|
if (patterns.length === 0) return [];
|
|
@@ -2653,6 +2698,20 @@ var init_prescribed = __esm({
|
|
|
2653
2698
|
(tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
|
|
2654
2699
|
);
|
|
2655
2700
|
if (practiceTags.length === 0) return [];
|
|
2701
|
+
const now = Date.now();
|
|
2702
|
+
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
2703
|
+
const tagMultiplier = /* @__PURE__ */ new Map();
|
|
2704
|
+
for (const tag of practiceTags) {
|
|
2705
|
+
const firstOwedAt = priorPracticeDebt[tag] ?? isoNow();
|
|
2706
|
+
nextPracticeDebt[tag] = firstOwedAt;
|
|
2707
|
+
const staleDays = Math.max(0, (now - new Date(firstOwedAt).getTime()) / DAY_MS);
|
|
2708
|
+
const mult = clamp(
|
|
2709
|
+
PRACTICE_BASE_MULT + staleDays * PRACTICE_STALENESS_BUMP_PER_DAY,
|
|
2710
|
+
PRACTICE_BASE_MULT,
|
|
2711
|
+
MAX_PRACTICE_MULTIPLIER
|
|
2712
|
+
);
|
|
2713
|
+
tagMultiplier.set(tag, mult);
|
|
2714
|
+
}
|
|
2656
2715
|
const practiceCardIds = this.findDiscoveredSupportCards({
|
|
2657
2716
|
supportTags: practiceTags,
|
|
2658
2717
|
cardsByTag,
|
|
@@ -2668,18 +2727,25 @@ var init_prescribed = __esm({
|
|
|
2668
2727
|
const cards = [];
|
|
2669
2728
|
for (const cardId of practiceCardIds) {
|
|
2670
2729
|
emittedIds.add(cardId);
|
|
2730
|
+
let mult = PRACTICE_BASE_MULT;
|
|
2731
|
+
for (const tag of practiceTags) {
|
|
2732
|
+
if (cardsByTag.get(tag)?.includes(cardId) ?? false) {
|
|
2733
|
+
mult = Math.max(mult, tagMultiplier.get(tag) ?? PRACTICE_BASE_MULT);
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
const score = BASE_PRACTICE_SCORE * mult;
|
|
2671
2737
|
cards.push({
|
|
2672
2738
|
cardId,
|
|
2673
2739
|
courseId,
|
|
2674
|
-
score
|
|
2740
|
+
score,
|
|
2675
2741
|
provenance: [
|
|
2676
2742
|
{
|
|
2677
2743
|
strategy: "prescribed",
|
|
2678
2744
|
strategyName: this.strategyName || this.name,
|
|
2679
2745
|
strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
|
|
2680
2746
|
action: "generated",
|
|
2681
|
-
score
|
|
2682
|
-
reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
|
|
2747
|
+
score,
|
|
2748
|
+
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}`
|
|
2683
2749
|
}
|
|
2684
2750
|
]
|
|
2685
2751
|
});
|
|
@@ -2852,15 +2918,16 @@ var srs_exports = {};
|
|
|
2852
2918
|
__export(srs_exports, {
|
|
2853
2919
|
default: () => SRSNavigator
|
|
2854
2920
|
});
|
|
2855
|
-
var import_moment3, DEFAULT_HEALTHY_BACKLOG,
|
|
2921
|
+
var import_moment3, DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_MULTIPLIER, SRSNavigator;
|
|
2856
2922
|
var init_srs = __esm({
|
|
2857
2923
|
"src/core/navigators/generators/srs.ts"() {
|
|
2858
2924
|
"use strict";
|
|
2859
2925
|
import_moment3 = __toESM(require("moment"), 1);
|
|
2860
2926
|
init_navigators();
|
|
2927
|
+
init_SrsDebugger();
|
|
2861
2928
|
init_logger();
|
|
2862
2929
|
DEFAULT_HEALTHY_BACKLOG = 20;
|
|
2863
|
-
|
|
2930
|
+
MAX_BACKLOG_MULTIPLIER = 2;
|
|
2864
2931
|
SRSNavigator = class extends ContentNavigator {
|
|
2865
2932
|
/** Human-readable name for CardGenerator interface */
|
|
2866
2933
|
name;
|
|
@@ -2927,9 +2994,18 @@ var init_srs = __esm({
|
|
|
2927
2994
|
}
|
|
2928
2995
|
}
|
|
2929
2996
|
}
|
|
2930
|
-
const
|
|
2997
|
+
const backlogMultiplier = this.computeBacklogMultiplier(dueReviews.length);
|
|
2998
|
+
const notDue = reviews.filter((r) => !now.isAfter(import_moment3.default.utc(r.reviewTime)));
|
|
2999
|
+
let nextDueIn = null;
|
|
3000
|
+
if (notDue.length > 0) {
|
|
3001
|
+
const next = notDue.reduce(
|
|
3002
|
+
(a, b) => import_moment3.default.utc(a.reviewTime).isBefore(import_moment3.default.utc(b.reviewTime)) ? a : b
|
|
3003
|
+
);
|
|
3004
|
+
const until = import_moment3.default.duration(import_moment3.default.utc(next.reviewTime).diff(now));
|
|
3005
|
+
nextDueIn = until.asHours() < 1 ? `${Math.round(until.asMinutes())}m` : until.asHours() < 24 ? `${Math.round(until.asHours())}h` : `${Math.round(until.asDays())}d`;
|
|
3006
|
+
}
|
|
2931
3007
|
if (dueReviews.length > 0) {
|
|
2932
|
-
const pressureNote =
|
|
3008
|
+
const pressureNote = backlogMultiplier > 1 ? ` [backlog pressure: \xD7${backlogMultiplier.toFixed(2)}]` : ` [healthy backlog]`;
|
|
2933
3009
|
logger.info(
|
|
2934
3010
|
`[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
|
|
2935
3011
|
);
|
|
@@ -2948,7 +3024,7 @@ var init_srs = __esm({
|
|
|
2948
3024
|
logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
|
|
2949
3025
|
}
|
|
2950
3026
|
const scored = dueReviews.map((review) => {
|
|
2951
|
-
const { score, reason } = this.computeUrgencyScore(review, now,
|
|
3027
|
+
const { score, reason } = this.computeUrgencyScore(review, now, backlogMultiplier);
|
|
2952
3028
|
return {
|
|
2953
3029
|
cardId: review.cardId,
|
|
2954
3030
|
courseId: review.courseId,
|
|
@@ -2966,30 +3042,42 @@ var init_srs = __esm({
|
|
|
2966
3042
|
]
|
|
2967
3043
|
};
|
|
2968
3044
|
});
|
|
2969
|
-
|
|
3045
|
+
const sorted = scored.sort((a, b) => b.score - a.score);
|
|
3046
|
+
captureSrsBacklog({
|
|
3047
|
+
courseId,
|
|
3048
|
+
scheduledTotal: reviews.length,
|
|
3049
|
+
dueNow: dueReviews.length,
|
|
3050
|
+
healthyBacklog: this.healthyBacklog,
|
|
3051
|
+
backlogMultiplier,
|
|
3052
|
+
maxBacklogMultiplier: MAX_BACKLOG_MULTIPLIER,
|
|
3053
|
+
topReviewScore: sorted.length > 0 ? sorted[0].score : null,
|
|
3054
|
+
nextDueIn,
|
|
3055
|
+
timestamp: Date.now()
|
|
3056
|
+
});
|
|
3057
|
+
return { cards: sorted.slice(0, limit) };
|
|
2970
3058
|
}
|
|
2971
3059
|
/**
|
|
2972
|
-
* Compute backlog pressure based on number of due reviews.
|
|
3060
|
+
* Compute the multiplicative backlog pressure based on number of due reviews.
|
|
2973
3061
|
*
|
|
2974
|
-
*
|
|
2975
|
-
* and
|
|
3062
|
+
* ×1.0 at or below the healthy threshold (no boost), increasing linearly above
|
|
3063
|
+
* it and maxing out at MAX_BACKLOG_MULTIPLIER at 3× the healthy backlog.
|
|
2976
3064
|
*
|
|
2977
|
-
* Examples (with default healthyBacklog=20):
|
|
2978
|
-
* - 10 due reviews →
|
|
2979
|
-
* - 20 due reviews →
|
|
2980
|
-
* - 40 due reviews →
|
|
2981
|
-
* - 60 due reviews →
|
|
3065
|
+
* Examples (with default healthyBacklog=20, MAX_BACKLOG_MULTIPLIER=2.0):
|
|
3066
|
+
* - 10 due reviews → ×1.00 (healthy)
|
|
3067
|
+
* - 20 due reviews → ×1.00 (at threshold)
|
|
3068
|
+
* - 40 due reviews → ×1.50 (2x threshold)
|
|
3069
|
+
* - 60 due reviews → ×2.00 (3x threshold, maxed)
|
|
2982
3070
|
*
|
|
2983
3071
|
* @param dueCount - Number of reviews currently due
|
|
2984
|
-
* @returns
|
|
3072
|
+
* @returns Multiplier applied to review urgency (1.0 to MAX_BACKLOG_MULTIPLIER)
|
|
2985
3073
|
*/
|
|
2986
|
-
|
|
3074
|
+
computeBacklogMultiplier(dueCount) {
|
|
2987
3075
|
if (dueCount <= this.healthyBacklog) {
|
|
2988
|
-
return
|
|
3076
|
+
return 1;
|
|
2989
3077
|
}
|
|
2990
3078
|
const excess = dueCount - this.healthyBacklog;
|
|
2991
|
-
const
|
|
2992
|
-
return Math.min(
|
|
3079
|
+
const multiplier = 1 + excess / this.healthyBacklog * ((MAX_BACKLOG_MULTIPLIER - 1) / 2);
|
|
3080
|
+
return Math.min(MAX_BACKLOG_MULTIPLIER, multiplier);
|
|
2993
3081
|
}
|
|
2994
3082
|
/**
|
|
2995
3083
|
* Compute urgency score for a review card.
|
|
@@ -3004,19 +3092,20 @@ var init_srs = __esm({
|
|
|
3004
3092
|
* - 30 days (720h) → ~0.56
|
|
3005
3093
|
* - 180 days → ~0.30
|
|
3006
3094
|
*
|
|
3007
|
-
* 3. Backlog pressure = global
|
|
3008
|
-
*
|
|
3009
|
-
* - At 2x healthy: +0.25
|
|
3010
|
-
* - At 3x+ healthy: +0.50 (max)
|
|
3095
|
+
* 3. Backlog pressure = global *multiplier* when review backlog exceeds the
|
|
3096
|
+
* healthy threshold (×1.0 healthy → up to MAX_BACKLOG_MULTIPLIER at 3×).
|
|
3011
3097
|
*
|
|
3012
|
-
* Combined: base 0.5 +
|
|
3013
|
-
*
|
|
3098
|
+
* Combined: (base 0.5 + urgency factors * 0.45) × backlog multiplier.
|
|
3099
|
+
* Per-card range before pressure: ~0.57–0.95. NOT clamped to 1.0 — under a
|
|
3100
|
+
* heavy backlog reviews scale onto the open scale to compete with (and exceed)
|
|
3101
|
+
* new cards; what keeps them from running away is the bounded multiplier, not
|
|
3102
|
+
* a hard ceiling.
|
|
3014
3103
|
*
|
|
3015
3104
|
* @param review - The scheduled card to score
|
|
3016
3105
|
* @param now - Current time
|
|
3017
|
-
* @param
|
|
3106
|
+
* @param backlogMultiplier - Pre-computed backlog multiplier (1.0 to MAX_BACKLOG_MULTIPLIER)
|
|
3018
3107
|
*/
|
|
3019
|
-
computeUrgencyScore(review, now,
|
|
3108
|
+
computeUrgencyScore(review, now, backlogMultiplier) {
|
|
3020
3109
|
const scheduledAt = import_moment3.default.utc(review.scheduledAt);
|
|
3021
3110
|
const due = import_moment3.default.utc(review.reviewTime);
|
|
3022
3111
|
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
@@ -3026,15 +3115,15 @@ var init_srs = __esm({
|
|
|
3026
3115
|
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
3027
3116
|
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
3028
3117
|
const baseScore = 0.5 + urgency * 0.45;
|
|
3029
|
-
const score =
|
|
3118
|
+
const score = baseScore * backlogMultiplier;
|
|
3030
3119
|
const reasonParts = [
|
|
3031
3120
|
`${Math.round(hoursOverdue)}h overdue`,
|
|
3032
3121
|
`interval: ${Math.round(intervalHours)}h`,
|
|
3033
3122
|
`relative: ${relativeOverdue.toFixed(2)}`,
|
|
3034
3123
|
`recency: ${recencyFactor.toFixed(2)}`
|
|
3035
3124
|
];
|
|
3036
|
-
if (
|
|
3037
|
-
reasonParts.push(`backlog:
|
|
3125
|
+
if (backlogMultiplier > 1) {
|
|
3126
|
+
reasonParts.push(`backlog: \xD7${backlogMultiplier.toFixed(2)}`);
|
|
3038
3127
|
}
|
|
3039
3128
|
reasonParts.push("review");
|
|
3040
3129
|
const reason = reasonParts.join(", ");
|
|
@@ -5006,6 +5095,68 @@ var init_Pipeline = __esm({
|
|
|
5006
5095
|
// ---------------------------------------------------------------------------
|
|
5007
5096
|
// Card-space diagnostic
|
|
5008
5097
|
// ---------------------------------------------------------------------------
|
|
5098
|
+
/**
|
|
5099
|
+
* Commit-free forecast: score the user's full card space through the filter
|
|
5100
|
+
* chain and return the cards that are currently *reachable* (score >=
|
|
5101
|
+
* threshold), optionally nudged by caller-supplied hints and/or restricted
|
|
5102
|
+
* to cards the user hasn't seen yet.
|
|
5103
|
+
*
|
|
5104
|
+
* This is a GENERIC primitive — it returns scored, tag-hydrated cards and
|
|
5105
|
+
* stops there. It has no knowledge of any particular tag convention; callers
|
|
5106
|
+
* decide what the surviving cards mean (e.g. filter to their own "intro"
|
|
5107
|
+
* tag family). Nothing is written and no session is started.
|
|
5108
|
+
*
|
|
5109
|
+
* The optional `hints` are the "out-of-band kick": they run through the same
|
|
5110
|
+
* {@link applyHints} path a live replan uses, so the two semantics carry over —
|
|
5111
|
+
* - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
|
|
5112
|
+
* stays out), and
|
|
5113
|
+
* - `requireTags`/`requireCards` inject from the full pre-filter pool,
|
|
5114
|
+
* *bypassing* gating (use when you want a card regardless of reachability).
|
|
5115
|
+
* Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
|
|
5116
|
+
* user has already seen — pass `unseenOnly: false` if that matters.
|
|
5117
|
+
*
|
|
5118
|
+
* Cost note: like {@link diagnoseCardSpace}, this scans every card through the
|
|
5119
|
+
* filters, so it's heavier than a normal replan. Intended for one-shot
|
|
5120
|
+
* out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
|
|
5121
|
+
*
|
|
5122
|
+
* @param opts.hints Optional ephemeral hints to apply after the filter chain.
|
|
5123
|
+
* @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
|
|
5124
|
+
* @param opts.threshold Min score to count as reachable (default 0.10).
|
|
5125
|
+
* @param opts.limit Optional cap on results (already sorted desc).
|
|
5126
|
+
*/
|
|
5127
|
+
async forecast(opts) {
|
|
5128
|
+
const threshold = opts?.threshold ?? 0.1;
|
|
5129
|
+
const unseenOnly = opts?.unseenOnly ?? true;
|
|
5130
|
+
const courseId = this.course.getCourseID();
|
|
5131
|
+
const allCardIds = await this.course.getAllCardIds();
|
|
5132
|
+
let cards = allCardIds.map((cardId) => ({
|
|
5133
|
+
cardId,
|
|
5134
|
+
courseId,
|
|
5135
|
+
score: 1,
|
|
5136
|
+
provenance: []
|
|
5137
|
+
}));
|
|
5138
|
+
cards = await this.hydrateTags(cards);
|
|
5139
|
+
const fullPool = cards.slice();
|
|
5140
|
+
const context = await this.buildContext();
|
|
5141
|
+
for (const filter of this.filters) {
|
|
5142
|
+
cards = await filter.transform(cards, context);
|
|
5143
|
+
}
|
|
5144
|
+
if (opts?.hints) {
|
|
5145
|
+
cards = this.applyHints(cards, opts.hints, fullPool);
|
|
5146
|
+
}
|
|
5147
|
+
cards = cards.filter((c) => c.score >= threshold);
|
|
5148
|
+
if (unseenOnly) {
|
|
5149
|
+
let encountered;
|
|
5150
|
+
try {
|
|
5151
|
+
encountered = new Set(await this.user.getSeenCards(courseId));
|
|
5152
|
+
} catch {
|
|
5153
|
+
encountered = /* @__PURE__ */ new Set();
|
|
5154
|
+
}
|
|
5155
|
+
cards = cards.filter((c) => !encountered.has(c.cardId));
|
|
5156
|
+
}
|
|
5157
|
+
cards.sort((a, b) => b.score - a.score);
|
|
5158
|
+
return opts?.limit ? cards.slice(0, opts.limit) : cards;
|
|
5159
|
+
}
|
|
5009
5160
|
/**
|
|
5010
5161
|
* Scan every card in the course through the filter chain and report
|
|
5011
5162
|
* how many are "well indicated" (score >= threshold) for the current user.
|
|
@@ -5270,6 +5421,7 @@ var init_3 = __esm({
|
|
|
5270
5421
|
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
5271
5422
|
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
5272
5423
|
"./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
|
|
5424
|
+
"./SrsDebugger.ts": () => Promise.resolve().then(() => (init_SrsDebugger(), SrsDebugger_exports)),
|
|
5273
5425
|
"./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
|
|
5274
5426
|
"./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
|
|
5275
5427
|
"./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
|
|
@@ -5302,11 +5454,14 @@ __export(navigators_exports, {
|
|
|
5302
5454
|
NavigatorRole: () => NavigatorRole,
|
|
5303
5455
|
NavigatorRoles: () => NavigatorRoles,
|
|
5304
5456
|
Navigators: () => Navigators,
|
|
5457
|
+
clearSrsBacklogDebug: () => clearSrsBacklogDebug,
|
|
5305
5458
|
diversityRerank: () => diversityRerank,
|
|
5459
|
+
getActivePipeline: () => getActivePipeline,
|
|
5306
5460
|
getCardOrigin: () => getCardOrigin,
|
|
5307
5461
|
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
5308
5462
|
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
5309
5463
|
getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
|
|
5464
|
+
getSrsBacklogDebug: () => getSrsBacklogDebug,
|
|
5310
5465
|
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
5311
5466
|
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
5312
5467
|
isFilter: () => isFilter,
|
|
@@ -5388,6 +5543,7 @@ var init_navigators = __esm({
|
|
|
5388
5543
|
"use strict";
|
|
5389
5544
|
init_diversityRerank();
|
|
5390
5545
|
init_PipelineDebugger();
|
|
5546
|
+
init_SrsDebugger();
|
|
5391
5547
|
init_logger();
|
|
5392
5548
|
init_();
|
|
5393
5549
|
init_2();
|
|
@@ -10410,6 +10566,7 @@ __export(index_exports, {
|
|
|
10410
10566
|
areQuestionRecords: () => areQuestionRecords,
|
|
10411
10567
|
buildStrategyStateId: () => buildStrategyStateId,
|
|
10412
10568
|
captureMixerRun: () => captureMixerRun,
|
|
10569
|
+
clearSrsBacklogDebug: () => clearSrsBacklogDebug,
|
|
10413
10570
|
computeDeviation: () => computeDeviation,
|
|
10414
10571
|
computeEffectiveWeight: () => computeEffectiveWeight,
|
|
10415
10572
|
computeOutcomeSignal: () => computeOutcomeSignal,
|
|
@@ -10420,6 +10577,7 @@ __export(index_exports, {
|
|
|
10420
10577
|
docIsDeleted: () => docIsDeleted,
|
|
10421
10578
|
endSessionTracking: () => endSessionTracking,
|
|
10422
10579
|
ensureAppDataDirectory: () => ensureAppDataDirectory,
|
|
10580
|
+
getActivePipeline: () => getActivePipeline,
|
|
10423
10581
|
getAppDataDirectory: () => getAppDataDirectory,
|
|
10424
10582
|
getCardHistoryID: () => getCardHistoryID,
|
|
10425
10583
|
getCardOrigin: () => getCardOrigin,
|
|
@@ -10429,6 +10587,7 @@ __export(index_exports, {
|
|
|
10429
10587
|
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
10430
10588
|
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
10431
10589
|
getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
|
|
10590
|
+
getSrsBacklogDebug: () => getSrsBacklogDebug,
|
|
10432
10591
|
getStudySource: () => getStudySource,
|
|
10433
10592
|
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
10434
10593
|
importParsedCards: () => importParsedCards,
|
|
@@ -13376,6 +13535,7 @@ mountMixerDebugger();
|
|
|
13376
13535
|
// src/study/SessionDebugger.ts
|
|
13377
13536
|
init_logger();
|
|
13378
13537
|
init_PipelineDebugger();
|
|
13538
|
+
init_SrsDebugger();
|
|
13379
13539
|
|
|
13380
13540
|
// src/study/SessionOverlay.ts
|
|
13381
13541
|
init_logger();
|
|
@@ -13397,8 +13557,7 @@ var lastSnapshot = null;
|
|
|
13397
13557
|
var copyFlashUntil = 0;
|
|
13398
13558
|
var minified = false;
|
|
13399
13559
|
var expanded = {
|
|
13400
|
-
|
|
13401
|
-
newQ: false,
|
|
13560
|
+
supplyQ: false,
|
|
13402
13561
|
failedQ: false,
|
|
13403
13562
|
drawn: false
|
|
13404
13563
|
};
|
|
@@ -13467,7 +13626,7 @@ function render() {
|
|
|
13467
13626
|
attachHandlers();
|
|
13468
13627
|
return;
|
|
13469
13628
|
}
|
|
13470
|
-
overlayEl.innerHTML = headerHtml() + replanHtml(s) + metaHtml(s) + hintsHtml(s.sessionHints) +
|
|
13629
|
+
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);
|
|
13471
13630
|
attachHandlers();
|
|
13472
13631
|
}
|
|
13473
13632
|
function attachHandlers() {
|
|
@@ -13559,6 +13718,29 @@ function hintsHtml(h) {
|
|
|
13559
13718
|
const body = parts.length ? parts.map((p) => `<div style="margin-left:6px">${p}</div>`).join("") : `<div style="margin-left:6px;opacity:.6">none</div>`;
|
|
13560
13719
|
return `<div style="margin-bottom:6px"><div style="color:#86efac">sessionHints</div>${body}</div>`;
|
|
13561
13720
|
}
|
|
13721
|
+
function backlogHtml(backlog) {
|
|
13722
|
+
if (!backlog.length) return "";
|
|
13723
|
+
const rows = backlog.map((b) => {
|
|
13724
|
+
const maxed = b.backlogMultiplier >= b.maxBacklogMultiplier - 1e-9;
|
|
13725
|
+
const multColor = b.backlogMultiplier <= 1 ? "#86efac" : maxed ? "#fca5a5" : "#fcd34d";
|
|
13726
|
+
const headroom = maxed ? "maxed \u2014 boosts decide order until they relax" : b.backlogMultiplier > 1 ? "climbing as backlog grows" : "healthy \u2014 no pressure";
|
|
13727
|
+
const top = b.topReviewScore !== null ? b.topReviewScore.toFixed(2) : "\u2014";
|
|
13728
|
+
const next = b.nextDueIn ? ` <span style="opacity:.6">\xB7 next due ${esc(b.nextDueIn)}</span>` : "";
|
|
13729
|
+
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>`;
|
|
13730
|
+
}).join("");
|
|
13731
|
+
return `<div style="margin-bottom:6px"><div style="color:#93c5fd">review backpressure</div>${rows}</div>`;
|
|
13732
|
+
}
|
|
13733
|
+
function fmtScore(score) {
|
|
13734
|
+
if (score === void 0) return "";
|
|
13735
|
+
if (!Number.isFinite(score)) return "REQ";
|
|
13736
|
+
return score.toFixed(2);
|
|
13737
|
+
}
|
|
13738
|
+
function queueItemHtml(item) {
|
|
13739
|
+
const tagColor = item.origin === "review" ? "#93c5fd" : "#fcd34d";
|
|
13740
|
+
const score = fmtScore(item.score);
|
|
13741
|
+
const label = `${item.origin === "review" ? "r" : "n"}${score ? " " + score : ""}`;
|
|
13742
|
+
return `${esc(item.cardID)}<span style="color:${tagColor};opacity:.85"> [${label}]</span>`;
|
|
13743
|
+
}
|
|
13562
13744
|
function queueHtml(key, label, q) {
|
|
13563
13745
|
const collapsible = q.length > INLINE_THRESHOLD;
|
|
13564
13746
|
const isOpen = collapsible && expanded[key];
|
|
@@ -13573,7 +13755,7 @@ function queueHtml(key, label, q) {
|
|
|
13573
13755
|
const shown = isOpen ? q.cards : q.cards.slice(0, INLINE_THRESHOLD);
|
|
13574
13756
|
const hiddenCount = q.length - shown.length;
|
|
13575
13757
|
const listMarginBottom = collapsible ? 2 : 6;
|
|
13576
|
-
let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">` + shown.map((c) => `<li style="white-space:nowrap">${
|
|
13758
|
+
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>`;
|
|
13577
13759
|
if (collapsible) {
|
|
13578
13760
|
const footer = isOpen ? "\u25BE show less" : `\u2026 +${hiddenCount} more`;
|
|
13579
13761
|
body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
|
|
@@ -13636,13 +13818,29 @@ function snapshotToText(s) {
|
|
|
13636
13818
|
if (h.excludeCards?.length) hintParts.push(` excludeCards: ${h.excludeCards.join(", ")}`);
|
|
13637
13819
|
}
|
|
13638
13820
|
lines.push(hintParts.length ? hintParts.join("\n") : " none");
|
|
13821
|
+
if (s.reviewBacklog.length) {
|
|
13822
|
+
lines.push("");
|
|
13823
|
+
lines.push("review backpressure:");
|
|
13824
|
+
for (const b of s.reviewBacklog) {
|
|
13825
|
+
const maxed = b.backlogMultiplier >= b.maxBacklogMultiplier - 1e-9;
|
|
13826
|
+
const headroom = maxed ? "maxed (boosts decide order)" : b.backlogMultiplier > 1 ? "climbing" : "healthy";
|
|
13827
|
+
const top = b.topReviewScore !== null ? b.topReviewScore.toFixed(2) : "\u2014";
|
|
13828
|
+
const next = b.nextDueIn ? `, next due ${b.nextDueIn}` : "";
|
|
13829
|
+
lines.push(
|
|
13830
|
+
` ${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}`
|
|
13831
|
+
);
|
|
13832
|
+
}
|
|
13833
|
+
}
|
|
13639
13834
|
const queueText = (label, q) => {
|
|
13640
13835
|
lines.push("");
|
|
13641
13836
|
lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
|
|
13642
|
-
q.cards.forEach((c, i) =>
|
|
13837
|
+
q.cards.forEach((c, i) => {
|
|
13838
|
+
const score = fmtScore(c.score);
|
|
13839
|
+
const tag = `${c.origin === "review" ? "r" : "n"}${score ? " " + score : ""}`;
|
|
13840
|
+
lines.push(` ${i + 1}. ${c.cardID} [${tag}]`);
|
|
13841
|
+
});
|
|
13643
13842
|
};
|
|
13644
|
-
queueText("
|
|
13645
|
-
queueText("newQ", s.newQ);
|
|
13843
|
+
queueText("supplyQ", s.supplyQ);
|
|
13646
13844
|
queueText("failedQ", s.failedQ);
|
|
13647
13845
|
lines.push("");
|
|
13648
13846
|
lines.push(`drawn: ${s.drawnCards.length}`);
|
|
@@ -13667,16 +13865,16 @@ function esc(value) {
|
|
|
13667
13865
|
var activeSession = null;
|
|
13668
13866
|
var sessionHistory = [];
|
|
13669
13867
|
var MAX_HISTORY = 5;
|
|
13670
|
-
function startSessionTracking(
|
|
13868
|
+
function startSessionTracking(supplyQLength, failedQLength) {
|
|
13671
13869
|
clearRunHistory();
|
|
13870
|
+
clearSrsBacklogDebug();
|
|
13672
13871
|
const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
13673
13872
|
activeSession = {
|
|
13674
13873
|
sessionId,
|
|
13675
13874
|
startTime: /* @__PURE__ */ new Date(),
|
|
13676
13875
|
initialQueues: {
|
|
13677
13876
|
timestamp: /* @__PURE__ */ new Date(),
|
|
13678
|
-
|
|
13679
|
-
newQLength,
|
|
13877
|
+
supplyQLength,
|
|
13680
13878
|
failedQLength
|
|
13681
13879
|
},
|
|
13682
13880
|
presentations: [],
|
|
@@ -13700,17 +13898,15 @@ function recordCardPresentation(cardId, courseId, courseName, origin, queueSourc
|
|
|
13700
13898
|
score
|
|
13701
13899
|
});
|
|
13702
13900
|
}
|
|
13703
|
-
function snapshotQueues(
|
|
13901
|
+
function snapshotQueues(supplyQLength, failedQLength, supplyQNext3) {
|
|
13704
13902
|
if (!activeSession) {
|
|
13705
13903
|
return;
|
|
13706
13904
|
}
|
|
13707
13905
|
activeSession.queueSnapshots.push({
|
|
13708
13906
|
timestamp: /* @__PURE__ */ new Date(),
|
|
13709
|
-
|
|
13710
|
-
newQLength,
|
|
13907
|
+
supplyQLength,
|
|
13711
13908
|
failedQLength,
|
|
13712
|
-
|
|
13713
|
-
newQNext3
|
|
13909
|
+
supplyQNext3
|
|
13714
13910
|
});
|
|
13715
13911
|
}
|
|
13716
13912
|
function endSessionTracking() {
|
|
@@ -13732,13 +13928,9 @@ function showCurrentQueue() {
|
|
|
13732
13928
|
}
|
|
13733
13929
|
const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
|
|
13734
13930
|
console.group("\u{1F4CA} Current Queue State");
|
|
13735
|
-
logger.info(`
|
|
13736
|
-
if (latest.
|
|
13737
|
-
logger.info(` Next: ${latest.
|
|
13738
|
-
}
|
|
13739
|
-
logger.info(`New Queue: ${latest.newQLength} cards`);
|
|
13740
|
-
if (latest.newQNext3 && latest.newQNext3.length > 0) {
|
|
13741
|
-
logger.info(` Next: ${latest.newQNext3.join(", ")}`);
|
|
13931
|
+
logger.info(`Supply Queue: ${latest.supplyQLength} cards`);
|
|
13932
|
+
if (latest.supplyQNext3 && latest.supplyQNext3.length > 0) {
|
|
13933
|
+
logger.info(` Next: ${latest.supplyQNext3.join(", ")}`);
|
|
13742
13934
|
}
|
|
13743
13935
|
logger.info(`Failed Queue: ${latest.failedQLength} cards`);
|
|
13744
13936
|
console.groupEnd();
|
|
@@ -13955,15 +14147,6 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13955
14147
|
* Individual replans can override via `ReplanOptions.limit`.
|
|
13956
14148
|
*/
|
|
13957
14149
|
_defaultBatchLimit = 20;
|
|
13958
|
-
/**
|
|
13959
|
-
* Maximum number of reviews enqueued at session start. Reviews live
|
|
13960
|
-
* outside the replan flow — the queue drains via consumption and is
|
|
13961
|
-
* not refilled mid-session. The session timer caps total review
|
|
13962
|
-
* exposure, so overfilling here is intentional. Default is generous
|
|
13963
|
-
* to accommodate Anki-style power users with hundreds of due reviews;
|
|
13964
|
-
* apps targeting nimbler sessions should override via constructor.
|
|
13965
|
-
*/
|
|
13966
|
-
_initialReviewCap = 200;
|
|
13967
14150
|
sources;
|
|
13968
14151
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
13969
14152
|
_sessionRecord = [];
|
|
@@ -13972,10 +14155,28 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13972
14155
|
}
|
|
13973
14156
|
// Session card stores
|
|
13974
14157
|
_currentCard = null;
|
|
13975
|
-
|
|
13976
|
-
|
|
14158
|
+
/**
|
|
14159
|
+
* The single supply queue: `new` + `review` items interleaved in pipeline
|
|
14160
|
+
* rank order (the mixer's score-ordered, source-interleaved output, with
|
|
14161
|
+
* `+INF` required cards floated to the front). Drawn front-to-back; reviews
|
|
14162
|
+
* and new compete on one cross-comparable scale rather than being re-mixed
|
|
14163
|
+
* by a probability gate. Replaced/re-ranked wholesale on replan. See
|
|
14164
|
+
* `docs/decision-single-supply-queue.md`.
|
|
14165
|
+
*/
|
|
14166
|
+
supplyQ = new ItemQueue();
|
|
13977
14167
|
failedQ = new ItemQueue();
|
|
13978
14168
|
// END Session card stores
|
|
14169
|
+
/**
|
|
14170
|
+
* Supply draws since the last failed-queue *event* (a failed draw, or a card
|
|
14171
|
+
* entering failedQ on failure). Drives the light steady failed-interleave
|
|
14172
|
+
* (§7): after this many consecutive supply draws, a pending failed card is
|
|
14173
|
+
* drawn so remediation doesn't starve mid-session. Incremented on each supply
|
|
14174
|
+
* draw; reset to 0 both when a failed card is drawn AND when one is added to
|
|
14175
|
+
* failedQ — the latter gives a just-failed card spacing instead of an instant
|
|
14176
|
+
* retry (the counter would otherwise already be ≥ threshold from the preceding
|
|
14177
|
+
* supply run).
|
|
14178
|
+
*/
|
|
14179
|
+
_supplyDrawsSinceFailed = 0;
|
|
13979
14180
|
/**
|
|
13980
14181
|
* Promise tracking a currently in-progress replan, or null if idle.
|
|
13981
14182
|
* Used by nextCard() to await completion before drawing from queues.
|
|
@@ -13989,8 +14190,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13989
14190
|
*/
|
|
13990
14191
|
_activeReplanLabel = null;
|
|
13991
14192
|
/**
|
|
13992
|
-
* Number of well-indicated
|
|
13993
|
-
* degrades to poorly-indicated content. Decremented on each
|
|
14193
|
+
* Number of well-indicated supply cards remaining before the queue
|
|
14194
|
+
* degrades to poorly-indicated content. Decremented on each supplyQ
|
|
13994
14195
|
* draw; when it hits 0, a replan is triggered automatically
|
|
13995
14196
|
* (user state has changed from completing good cards).
|
|
13996
14197
|
*/
|
|
@@ -13999,7 +14200,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13999
14200
|
* When true, suppresses the quality-based auto-replan trigger in
|
|
14000
14201
|
* nextCard(). Set after a burst replan (small limit) to prevent the
|
|
14001
14202
|
* auto-replan from clobbering the burst cards before they're consumed.
|
|
14002
|
-
* Cleared when the depletion-triggered replan fires (
|
|
14203
|
+
* Cleared when the depletion-triggered replan fires (supplyQ exhausted).
|
|
14003
14204
|
*/
|
|
14004
14205
|
_suppressQualityReplan = false;
|
|
14005
14206
|
/**
|
|
@@ -14028,13 +14229,15 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14028
14229
|
* a draw the instant it happens — earlier than `_sessionRecord`, which only
|
|
14029
14230
|
* lands once the card is *responded to*.
|
|
14030
14231
|
*
|
|
14031
|
-
* Used to keep already-served cards out of
|
|
14032
|
-
* card shown once must never re-enter
|
|
14033
|
-
*
|
|
14034
|
-
*
|
|
14035
|
-
*
|
|
14036
|
-
*
|
|
14037
|
-
*
|
|
14232
|
+
* Used to keep already-served cards out of supplyQ on every (re)plan, across
|
|
14233
|
+
* ALL origins: a `new` card shown once must never re-enter, and once replans
|
|
14234
|
+
* re-pull reviews, an answered/in-flight review must not re-enter the supply
|
|
14235
|
+
* before its SRS reschedule clears the due-window (the review-loop guard,
|
|
14236
|
+
* decision doc §4). This is the general guard against re-presentation —
|
|
14237
|
+
* including the case where a replan in flight captured a now-drawn card (e.g.
|
|
14238
|
+
* a +INF require-injected follow-up the depletion prefetch grabbed just before
|
|
14239
|
+
* it was drawn). failedQ is separate and controller-owned, so failed cards
|
|
14240
|
+
* legitimately recur there without being gated here.
|
|
14038
14241
|
*/
|
|
14039
14242
|
_servedCardIds = /* @__PURE__ */ new Set();
|
|
14040
14243
|
/**
|
|
@@ -14059,14 +14262,12 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14059
14262
|
return this._minCardsGuarantee > 0;
|
|
14060
14263
|
}
|
|
14061
14264
|
get report() {
|
|
14062
|
-
const
|
|
14063
|
-
const
|
|
14064
|
-
|
|
14065
|
-
const newCardWord = newCount === 1 ? "new card" : "new cards";
|
|
14066
|
-
return `${reviewCount} ${reviewWord}, ${newCount} ${newCardWord}`;
|
|
14265
|
+
const supplyCount = this.supplyQ.dequeueCount;
|
|
14266
|
+
const supplyWord = supplyCount === 1 ? "card" : "cards";
|
|
14267
|
+
return `${supplyCount} supply ${supplyWord} drawn`;
|
|
14067
14268
|
}
|
|
14068
14269
|
get detailedReport() {
|
|
14069
|
-
return this.
|
|
14270
|
+
return this.supplyQ.toString + "\n" + this.failedQ.toString;
|
|
14070
14271
|
}
|
|
14071
14272
|
// @ts-expect-error NodeJS.Timeout type not available in browser context
|
|
14072
14273
|
_intervalHandle;
|
|
@@ -14077,11 +14278,9 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14077
14278
|
* @param getViewComponent - Function to resolve view components
|
|
14078
14279
|
* @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
|
|
14079
14280
|
* @param options - Optional session-level configuration
|
|
14080
|
-
* @param options.defaultBatchLimit - Default
|
|
14281
|
+
* @param options.defaultBatchLimit - Default supply working-set size (default: 20).
|
|
14081
14282
|
* Smaller values for newer users cause more frequent replans, keeping plans
|
|
14082
14283
|
* aligned with rapidly-changing user state.
|
|
14083
|
-
* @param options.initialReviewCap - Max reviews loaded at session start (default: 200).
|
|
14084
|
-
* Applied only on initial planning; replans do not refill the review queue.
|
|
14085
14284
|
*/
|
|
14086
14285
|
constructor(sources, time, dataLayer, getViewComponent, mixer, options) {
|
|
14087
14286
|
super();
|
|
@@ -14104,17 +14303,13 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14104
14303
|
if (options?.defaultBatchLimit !== void 0) {
|
|
14105
14304
|
this._defaultBatchLimit = options.defaultBatchLimit;
|
|
14106
14305
|
}
|
|
14107
|
-
if (options?.initialReviewCap !== void 0) {
|
|
14108
|
-
this._initialReviewCap = options.initialReviewCap;
|
|
14109
|
-
}
|
|
14110
14306
|
if (options?.outcomeObservers?.length) {
|
|
14111
14307
|
this._outcomeObservers = [...options.outcomeObservers];
|
|
14112
14308
|
}
|
|
14113
14309
|
this.log(`Session constructed:
|
|
14114
14310
|
startTime: ${this.startTime}
|
|
14115
14311
|
endTime: ${this.endTime}
|
|
14116
|
-
defaultBatchLimit: ${this._defaultBatchLimit}
|
|
14117
|
-
initialReviewCap: ${this._initialReviewCap}`);
|
|
14312
|
+
defaultBatchLimit: ${this._defaultBatchLimit}`);
|
|
14118
14313
|
registerActiveController(this);
|
|
14119
14314
|
}
|
|
14120
14315
|
tick() {
|
|
@@ -14148,15 +14343,6 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14148
14343
|
this.log(`Failed card cleanup estimate: ${Math.round(ret)}`);
|
|
14149
14344
|
return ret;
|
|
14150
14345
|
}
|
|
14151
|
-
/**
|
|
14152
|
-
* Extremely rough, conservative, estimate of amound of time to complete
|
|
14153
|
-
* all scheduled reviews
|
|
14154
|
-
*/
|
|
14155
|
-
estimateReviewTime() {
|
|
14156
|
-
const ret = 5 * this.reviewQ.length;
|
|
14157
|
-
this.log(`Review card time estimate: ${ret}`);
|
|
14158
|
-
return ret;
|
|
14159
|
-
}
|
|
14160
14346
|
async prepareSession() {
|
|
14161
14347
|
if (this.sources.some((s) => typeof s.getWeightedCards !== "function")) {
|
|
14162
14348
|
throw new Error(
|
|
@@ -14171,15 +14357,15 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14171
14357
|
);
|
|
14172
14358
|
}
|
|
14173
14359
|
await this.hydrationService.ensureHydratedCards();
|
|
14174
|
-
startSessionTracking(this.
|
|
14360
|
+
startSessionTracking(this.supplyQ.length, this.failedQ.length);
|
|
14175
14361
|
this._intervalHandle = setInterval(() => {
|
|
14176
14362
|
this.tick();
|
|
14177
14363
|
}, 1e3);
|
|
14178
14364
|
}
|
|
14179
14365
|
/**
|
|
14180
14366
|
* Request a mid-session replan. Re-runs the pipeline with current user state
|
|
14181
|
-
* and atomically replaces the
|
|
14182
|
-
* a session.
|
|
14367
|
+
* and atomically replaces (or merges into) the supplyQ contents. Safe to call
|
|
14368
|
+
* at any time during a session.
|
|
14183
14369
|
*
|
|
14184
14370
|
* Concurrency policy:
|
|
14185
14371
|
* - Two unhinted auto-replans never run in parallel; the second coalesces
|
|
@@ -14193,7 +14379,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14193
14379
|
* results (e.g. surfacing another gpc-intro card right after one
|
|
14194
14380
|
* completed, skipping the prescribed `c-wst-*` follow-up).
|
|
14195
14381
|
*
|
|
14196
|
-
*
|
|
14382
|
+
* Re-pulls and re-ranks the whole supply (including reviews); does NOT affect
|
|
14383
|
+
* failedQ (controller-owned remediation).
|
|
14197
14384
|
*
|
|
14198
14385
|
* If nextCard() is called while a replan is in flight, it will automatically
|
|
14199
14386
|
* await the replan before drawing from queues, ensuring the user always sees
|
|
@@ -14259,7 +14446,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14259
14446
|
* excludeCards happen at *invocation* time, not at *queue* time. For a
|
|
14260
14447
|
* queued replan that means excludes reflect the state after the prior
|
|
14261
14448
|
* replan landed — which is what we want, since the prior replan's
|
|
14262
|
-
*
|
|
14449
|
+
* supplyQ.peek(0) is the imminent draw we need to exclude.
|
|
14263
14450
|
*/
|
|
14264
14451
|
async _runReplan(opts) {
|
|
14265
14452
|
this._activeReplanLabel = opts.label ?? "(auto)";
|
|
@@ -14272,8 +14459,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14272
14459
|
for (const rec of this._sessionRecord) {
|
|
14273
14460
|
excludeSet.add(rec.card.card_id);
|
|
14274
14461
|
}
|
|
14275
|
-
if (this.
|
|
14276
|
-
excludeSet.add(this.
|
|
14462
|
+
if (this.supplyQ.length > 0) {
|
|
14463
|
+
excludeSet.add(this.supplyQ.peek(0).cardID);
|
|
14277
14464
|
}
|
|
14278
14465
|
hints.excludeCards = [...excludeSet];
|
|
14279
14466
|
if (opts.sessionHints !== void 0) {
|
|
@@ -14334,7 +14521,13 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14334
14521
|
const describe = (q) => {
|
|
14335
14522
|
const cards = [];
|
|
14336
14523
|
for (let i = 0; i < q.length; i++) {
|
|
14337
|
-
|
|
14524
|
+
const item = q.peek(i);
|
|
14525
|
+
cards.push({
|
|
14526
|
+
cardID: item.cardID,
|
|
14527
|
+
status: item.status,
|
|
14528
|
+
origin: isReview(item) ? "review" : "new",
|
|
14529
|
+
score: item.score
|
|
14530
|
+
});
|
|
14338
14531
|
}
|
|
14339
14532
|
return { length: q.length, dequeueCount: q.dequeueCount, cards };
|
|
14340
14533
|
};
|
|
@@ -14357,9 +14550,9 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14357
14550
|
sessionHints: this._sessionHints,
|
|
14358
14551
|
replanActive: this._replanPromise !== null,
|
|
14359
14552
|
replanLabel: this._activeReplanLabel,
|
|
14360
|
-
|
|
14361
|
-
newQ: describe(this.newQ),
|
|
14553
|
+
supplyQ: describe(this.supplyQ),
|
|
14362
14554
|
failedQ: describe(this.failedQ),
|
|
14555
|
+
reviewBacklog: getSrsBacklogDebug(),
|
|
14363
14556
|
drawnCards
|
|
14364
14557
|
};
|
|
14365
14558
|
}
|
|
@@ -14479,7 +14672,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14479
14672
|
*/
|
|
14480
14673
|
static WELL_INDICATED_SCORE = 0.1;
|
|
14481
14674
|
/**
|
|
14482
|
-
*
|
|
14675
|
+
* supplyQ length at or below which the opportunistic depletion-prefetch
|
|
14483
14676
|
* fires. Sets the lead time available for the background replan to land
|
|
14484
14677
|
* before the user actually empties the queue and falls into the
|
|
14485
14678
|
* (synchronous) wedge-breaker path.
|
|
@@ -14492,7 +14685,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14492
14685
|
*/
|
|
14493
14686
|
static DEPLETION_PREFETCH_THRESHOLD = 3;
|
|
14494
14687
|
/**
|
|
14495
|
-
* Internal replan execution. Runs the pipeline,
|
|
14688
|
+
* Internal replan execution. Runs the pipeline, rebuilds the supplyQ,
|
|
14496
14689
|
* atomically swaps it in, and triggers hydration for the new contents.
|
|
14497
14690
|
*
|
|
14498
14691
|
* If the initial replan produces fewer than MIN_WELL_INDICATED cards that
|
|
@@ -14521,8 +14714,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14521
14714
|
}
|
|
14522
14715
|
await this.hydrationService.ensureHydratedCards();
|
|
14523
14716
|
const labelTag = opts.label ? ` [${opts.label}]` : "";
|
|
14524
|
-
this.log(`Replan complete${labelTag}:
|
|
14525
|
-
snapshotQueues(this.
|
|
14717
|
+
this.log(`Replan complete${labelTag}: supplyQ now has ${this.supplyQ.length} cards (mode=${mode})`);
|
|
14718
|
+
snapshotQueues(this.supplyQ.length, this.failedQ.length);
|
|
14526
14719
|
}
|
|
14527
14720
|
addTime(seconds) {
|
|
14528
14721
|
this.endTime = new Date(this.endTime.valueOf() + 1e3 * seconds);
|
|
@@ -14531,10 +14724,10 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14531
14724
|
return this.failedQ.length;
|
|
14532
14725
|
}
|
|
14533
14726
|
toString() {
|
|
14534
|
-
return `Session: ${this.
|
|
14727
|
+
return `Session: ${this.supplyQ.length} supply, ${this.failedQ.length} failed`;
|
|
14535
14728
|
}
|
|
14536
14729
|
reportString() {
|
|
14537
|
-
return `${this.
|
|
14730
|
+
return `${this.supplyQ.dequeueCount} supply, ${this.failedQ.dequeueCount} failed`;
|
|
14538
14731
|
}
|
|
14539
14732
|
/**
|
|
14540
14733
|
* Returns debug information about the current session state.
|
|
@@ -14551,7 +14744,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14551
14744
|
items.push({
|
|
14552
14745
|
courseID: item.courseID || "unknown",
|
|
14553
14746
|
cardID: item.cardID || "unknown",
|
|
14554
|
-
status: item.status || "unknown"
|
|
14747
|
+
status: item.status || "unknown",
|
|
14748
|
+
score: item.score
|
|
14555
14749
|
});
|
|
14556
14750
|
}
|
|
14557
14751
|
return items;
|
|
@@ -14561,15 +14755,10 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14561
14755
|
mode: supportsWeightedCards ? "weighted" : "legacy",
|
|
14562
14756
|
description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "ERROR: getWeightedCards() not a function."
|
|
14563
14757
|
},
|
|
14564
|
-
|
|
14565
|
-
length: this.
|
|
14566
|
-
dequeueCount: this.
|
|
14567
|
-
items: extractQueueItems(this.
|
|
14568
|
-
},
|
|
14569
|
-
newQueue: {
|
|
14570
|
-
length: this.newQ.length,
|
|
14571
|
-
dequeueCount: this.newQ.dequeueCount,
|
|
14572
|
-
items: extractQueueItems(this.newQ)
|
|
14758
|
+
supplyQueue: {
|
|
14759
|
+
length: this.supplyQ.length,
|
|
14760
|
+
dequeueCount: this.supplyQ.dequeueCount,
|
|
14761
|
+
items: extractQueueItems(this.supplyQ)
|
|
14573
14762
|
},
|
|
14574
14763
|
failedQueue: {
|
|
14575
14764
|
length: this.failedQ.length,
|
|
@@ -14589,30 +14778,29 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14589
14778
|
};
|
|
14590
14779
|
}
|
|
14591
14780
|
/**
|
|
14592
|
-
* Fetch content
|
|
14781
|
+
* Fetch weighted content from all sources, mix across sources, and populate
|
|
14782
|
+
* the single supply queue in pipeline rank order.
|
|
14593
14783
|
*
|
|
14594
|
-
*
|
|
14595
|
-
* 1.
|
|
14596
|
-
*
|
|
14597
|
-
*
|
|
14598
|
-
*
|
|
14599
|
-
|
|
14600
|
-
/**
|
|
14601
|
-
* Fetch weighted content from all sources and populate session queues.
|
|
14784
|
+
* Reviews and new cards compete on one cross-comparable scale (SRS 0.5–1.0
|
|
14785
|
+
* w/ backlog pressure vs ELO 0.0–1.0) — there is no origin split and no
|
|
14786
|
+
* second mixer. The working set is `supplyLimit` cards (the top of the mixed
|
|
14787
|
+
* ranking, plus any `+INF` required cards floated to the front); replans
|
|
14788
|
+
* re-pull and re-rank the whole supply, so a heavy review backlog surfaces as
|
|
14789
|
+
* a refreshed top-ranked working set rather than a frozen 200-card snapshot.
|
|
14602
14790
|
*
|
|
14603
14791
|
* @param options.replan - If true, this is a mid-session replan rather than
|
|
14604
|
-
* initial session setup.
|
|
14605
|
-
*
|
|
14606
|
-
* @param options.additive - If true (replan only), merge
|
|
14607
|
-
* candidates into the front of the existing
|
|
14792
|
+
* initial session setup. Atomically replaces supplyQ contents and treats
|
|
14793
|
+
* empty results as non-fatal.
|
|
14794
|
+
* @param options.additive - If true (replan only), merge high-quality
|
|
14795
|
+
* candidates into the front of the existing supplyQ instead of replacing it.
|
|
14608
14796
|
* @returns Number of "well-indicated" cards (passed all hierarchy filters)
|
|
14609
14797
|
* in the new content. Returns -1 if no content was loaded.
|
|
14610
14798
|
*/
|
|
14611
14799
|
async getWeightedContent(options) {
|
|
14612
14800
|
const replan = options?.replan ?? false;
|
|
14613
14801
|
const additive = options?.additive ?? false;
|
|
14614
|
-
const
|
|
14615
|
-
const fetchLimit =
|
|
14802
|
+
const supplyLimit = options?.limit ?? this._defaultBatchLimit;
|
|
14803
|
+
const fetchLimit = supplyLimit;
|
|
14616
14804
|
if (!replan) {
|
|
14617
14805
|
this._applyHintsToSources();
|
|
14618
14806
|
}
|
|
@@ -14634,7 +14822,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14634
14822
|
}
|
|
14635
14823
|
if (batches.length === 0) {
|
|
14636
14824
|
if (replan) {
|
|
14637
|
-
this.log("Replan: no content from any source, keeping existing
|
|
14825
|
+
this.log("Replan: no content from any source, keeping existing supplyQ");
|
|
14638
14826
|
return -1;
|
|
14639
14827
|
}
|
|
14640
14828
|
throw new Error(
|
|
@@ -14668,64 +14856,59 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14668
14856
|
quotaPerSource,
|
|
14669
14857
|
mixedWeighted
|
|
14670
14858
|
);
|
|
14671
|
-
const
|
|
14672
|
-
const
|
|
14673
|
-
|
|
14674
|
-
|
|
14675
|
-
const mandatoryWeighted = newCandidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
|
|
14676
|
-
const optionalWeighted = newCandidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
|
|
14677
|
-
const newWeighted = [
|
|
14859
|
+
const candidates = mixedWeighted.filter((w) => !this._servedCardIds.has(w.cardId));
|
|
14860
|
+
const mandatoryWeighted = candidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
|
|
14861
|
+
const optionalWeighted = candidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
|
|
14862
|
+
const supplyWeighted = [
|
|
14678
14863
|
...mandatoryWeighted,
|
|
14679
|
-
...optionalWeighted.slice(0, Math.max(0,
|
|
14864
|
+
...optionalWeighted.slice(0, Math.max(0, supplyLimit - mandatoryWeighted.length))
|
|
14680
14865
|
];
|
|
14681
14866
|
const mandatoryIds = new Set(mandatoryWeighted.map((w) => w.cardId));
|
|
14682
|
-
|
|
14683
|
-
let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
|
|
14684
|
-
if (!replan) {
|
|
14685
|
-
for (const w of reviewWeighted) {
|
|
14686
|
-
const reviewItem = {
|
|
14687
|
-
cardID: w.cardId,
|
|
14688
|
-
courseID: w.courseId,
|
|
14689
|
-
contentSourceType: "course",
|
|
14690
|
-
contentSourceID: w.courseId,
|
|
14691
|
-
reviewID: w.reviewID,
|
|
14692
|
-
status: "review"
|
|
14693
|
-
};
|
|
14694
|
-
this.reviewQ.add(reviewItem, reviewItem.cardID);
|
|
14695
|
-
report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
14696
|
-
`;
|
|
14697
|
-
}
|
|
14698
|
-
}
|
|
14699
|
-
const wellIndicated = newWeighted.filter(
|
|
14867
|
+
const wellIndicated = supplyWeighted.filter(
|
|
14700
14868
|
(w) => w.score >= _SessionController.WELL_INDICATED_SCORE
|
|
14701
14869
|
).length;
|
|
14702
|
-
|
|
14703
|
-
|
|
14704
|
-
const
|
|
14705
|
-
|
|
14706
|
-
|
|
14707
|
-
contentSourceType: "course",
|
|
14708
|
-
contentSourceID: w.courseId,
|
|
14709
|
-
status: "new"
|
|
14710
|
-
};
|
|
14711
|
-
newItems.push(newItem);
|
|
14712
|
-
report += `New: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
14870
|
+
let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
|
|
14871
|
+
const supplyItems = supplyWeighted.map((w) => {
|
|
14872
|
+
const origin = getCardOrigin(w);
|
|
14873
|
+
const scoreStr = Number.isFinite(w.score) ? w.score.toFixed(2) : "+INF";
|
|
14874
|
+
report += `${origin === "review" ? "Review" : "New"}: ${w.courseId}::${w.cardId} (score: ${scoreStr})
|
|
14713
14875
|
`;
|
|
14714
|
-
|
|
14876
|
+
return this._buildSupplyItem(w, origin);
|
|
14877
|
+
});
|
|
14715
14878
|
if (additive) {
|
|
14716
|
-
const added = this.
|
|
14717
|
-
report += `Additive merge: ${added}
|
|
14879
|
+
const added = this.supplyQ.mergeToFront(supplyItems, (item) => item.cardID, mandatoryIds);
|
|
14880
|
+
report += `Additive merge: ${added} cards added to front of supplyQ
|
|
14718
14881
|
`;
|
|
14719
14882
|
} else if (replan) {
|
|
14720
|
-
this.
|
|
14883
|
+
this.supplyQ.replaceAll(supplyItems, (item) => item.cardID);
|
|
14721
14884
|
} else {
|
|
14722
|
-
for (const item of
|
|
14723
|
-
this.
|
|
14885
|
+
for (const item of supplyItems) {
|
|
14886
|
+
this.supplyQ.add(item, item.cardID);
|
|
14724
14887
|
}
|
|
14725
14888
|
}
|
|
14726
14889
|
this.log(report);
|
|
14727
14890
|
return wellIndicated;
|
|
14728
14891
|
}
|
|
14892
|
+
/**
|
|
14893
|
+
* Build a supply item from a weighted candidate. Review-origin cards carry
|
|
14894
|
+
* their `reviewID` so SRS outcome tracking and re-presentation work; new
|
|
14895
|
+
* cards do not. `score` is carried on both for the debug overlay.
|
|
14896
|
+
*/
|
|
14897
|
+
_buildSupplyItem(w, origin = getCardOrigin(w)) {
|
|
14898
|
+
const base = {
|
|
14899
|
+
cardID: w.cardId,
|
|
14900
|
+
courseID: w.courseId,
|
|
14901
|
+
contentSourceType: "course",
|
|
14902
|
+
contentSourceID: w.courseId,
|
|
14903
|
+
score: w.score
|
|
14904
|
+
};
|
|
14905
|
+
if (origin === "review") {
|
|
14906
|
+
const reviewItem = { ...base, status: "review", reviewID: w.reviewID };
|
|
14907
|
+
return reviewItem;
|
|
14908
|
+
}
|
|
14909
|
+
const newItem = { ...base, status: "new" };
|
|
14910
|
+
return newItem;
|
|
14911
|
+
}
|
|
14729
14912
|
/**
|
|
14730
14913
|
* Returns items that should be pre-hydrated.
|
|
14731
14914
|
* Deterministic: top N items from each queue to ensure coverage.
|
|
@@ -14733,71 +14916,73 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14733
14916
|
*/
|
|
14734
14917
|
_getItemsToHydrate() {
|
|
14735
14918
|
const items = [];
|
|
14736
|
-
const
|
|
14737
|
-
|
|
14738
|
-
|
|
14739
|
-
|
|
14740
|
-
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.newQ.length); i++) {
|
|
14741
|
-
items.push(this.newQ.peek(i));
|
|
14919
|
+
const SUPPLY_PREFETCH = 3;
|
|
14920
|
+
const FAILED_PREFETCH = 2;
|
|
14921
|
+
for (let i = 0; i < Math.min(SUPPLY_PREFETCH, this.supplyQ.length); i++) {
|
|
14922
|
+
items.push(this.supplyQ.peek(i));
|
|
14742
14923
|
}
|
|
14743
|
-
for (let i = 0; i < Math.min(
|
|
14924
|
+
for (let i = 0; i < Math.min(FAILED_PREFETCH, this.failedQ.length); i++) {
|
|
14744
14925
|
items.push(this.failedQ.peek(i));
|
|
14745
14926
|
}
|
|
14746
14927
|
return items;
|
|
14747
14928
|
}
|
|
14748
14929
|
/**
|
|
14749
14930
|
* Selects the next item to present to the user.
|
|
14750
|
-
*
|
|
14931
|
+
*
|
|
14932
|
+
* The supplyQ is already rank-ordered (the pipeline + mixer did the mixing,
|
|
14933
|
+
* with `+INF` required cards floated to the front), so the primary path is a
|
|
14934
|
+
* deterministic front-to-back draw — no second new-vs-review mixer. The only
|
|
14935
|
+
* remaining decisions are (a) when the session ends and (b) when to interleave
|
|
14936
|
+
* a remediation card from failedQ. See decision doc §2/§3/§7.
|
|
14751
14937
|
*/
|
|
14752
14938
|
_selectNextItemToHydrate() {
|
|
14753
|
-
|
|
14754
|
-
let newBound = 0.1;
|
|
14755
|
-
let reviewBound = 0.75;
|
|
14756
|
-
if (this.reviewQ.length === 0 && this.failedQ.length === 0 && this.newQ.length === 0) {
|
|
14939
|
+
if (this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
14757
14940
|
return null;
|
|
14758
14941
|
}
|
|
14759
14942
|
if (this._secondsRemaining < 2 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
|
|
14760
14943
|
return null;
|
|
14761
14944
|
}
|
|
14762
14945
|
if (this._secondsRemaining <= 0 && this._minCardsGuarantee <= 0) {
|
|
14763
|
-
|
|
14764
|
-
return this.failedQ.peek(0);
|
|
14765
|
-
} else {
|
|
14766
|
-
return null;
|
|
14767
|
-
}
|
|
14946
|
+
return this.failedQ.length > 0 ? this.failedQ.peek(0) : null;
|
|
14768
14947
|
}
|
|
14769
|
-
|
|
14770
|
-
|
|
14948
|
+
const supplyTop = this.supplyQ.length > 0 ? this.supplyQ.peek(0) : null;
|
|
14949
|
+
if (this._minCardsGuarantee > 0 && supplyTop) {
|
|
14950
|
+
return supplyTop;
|
|
14771
14951
|
}
|
|
14772
|
-
|
|
14773
|
-
|
|
14774
|
-
const availableTime = this._secondsRemaining - (cleanupTime + reviewTime);
|
|
14775
|
-
if (availableTime > 20) {
|
|
14776
|
-
newBound = 0.5;
|
|
14777
|
-
reviewBound = 0.9;
|
|
14778
|
-
} else if (this._secondsRemaining - cleanupTime > 20) {
|
|
14779
|
-
newBound = 0.05;
|
|
14780
|
-
reviewBound = 0.9;
|
|
14781
|
-
} else {
|
|
14782
|
-
newBound = 0.01;
|
|
14783
|
-
reviewBound = 0.1;
|
|
14784
|
-
}
|
|
14785
|
-
if (this.failedQ.length === 0) {
|
|
14786
|
-
reviewBound = 1;
|
|
14952
|
+
if (this.failedQ.length > 0 && this._shouldInterleaveFailed(supplyTop !== null)) {
|
|
14953
|
+
return this.failedQ.peek(0);
|
|
14787
14954
|
}
|
|
14788
|
-
if (
|
|
14789
|
-
|
|
14955
|
+
if (supplyTop) {
|
|
14956
|
+
return supplyTop;
|
|
14790
14957
|
}
|
|
14791
|
-
if (
|
|
14792
|
-
return this.newQ.peek(0);
|
|
14793
|
-
} else if (choice < reviewBound && this.reviewQ.length) {
|
|
14794
|
-
return this.reviewQ.peek(0);
|
|
14795
|
-
} else if (this.failedQ.length) {
|
|
14958
|
+
if (this.failedQ.length > 0) {
|
|
14796
14959
|
return this.failedQ.peek(0);
|
|
14797
|
-
} else {
|
|
14798
|
-
this.log(`No more cards available for the session!`);
|
|
14799
|
-
return null;
|
|
14800
14960
|
}
|
|
14961
|
+
this.log(`No more cards available for the session!`);
|
|
14962
|
+
return null;
|
|
14963
|
+
}
|
|
14964
|
+
/** Supply draws between forced failed-queue interleaves (light steady cadence). */
|
|
14965
|
+
static FAILED_INTERLEAVE_EVERY = 4;
|
|
14966
|
+
/**
|
|
14967
|
+
* Slack (seconds) below which the endgame failed-pressure kicks in: when the
|
|
14968
|
+
* time left after clearing remediation drops under this, bias hard to failed
|
|
14969
|
+
* so the session doesn't end with un-cleared remediation. Mirrors the old
|
|
14970
|
+
* `availableTime > 20` ladder thresholds.
|
|
14971
|
+
*/
|
|
14972
|
+
static FAILED_ENDGAME_SLACK_SECONDS = 20;
|
|
14973
|
+
/**
|
|
14974
|
+
* Whether to interleave a failed (remediation) card now instead of drawing
|
|
14975
|
+
* the supply head. Replaces the old `newBound`/`reviewBound` probability
|
|
14976
|
+
* ladder's failed path (decision doc §7).
|
|
14977
|
+
*
|
|
14978
|
+
* @param supplyAvailable - whether supplyQ has a card to draw instead.
|
|
14979
|
+
*/
|
|
14980
|
+
_shouldInterleaveFailed(supplyAvailable) {
|
|
14981
|
+
if (this.failedQ.length === 0) return false;
|
|
14982
|
+
if (!supplyAvailable) return true;
|
|
14983
|
+
const availableTime = this._secondsRemaining - this.estimateCleanupTime();
|
|
14984
|
+
if (availableTime <= _SessionController.FAILED_ENDGAME_SLACK_SECONDS) return true;
|
|
14985
|
+
return this._supplyDrawsSinceFailed >= _SessionController.FAILED_INTERLEAVE_EVERY;
|
|
14801
14986
|
}
|
|
14802
14987
|
async nextCard(action = "dismiss-success") {
|
|
14803
14988
|
this.dismissCurrentCard(action);
|
|
@@ -14805,22 +14990,21 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14805
14990
|
this._minCardsGuarantee--;
|
|
14806
14991
|
this.log(`[CardGuarantee] ${this._minCardsGuarantee} guaranteed cards remaining`);
|
|
14807
14992
|
}
|
|
14808
|
-
if (this._replanPromise && this.
|
|
14993
|
+
if (this._replanPromise && this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
14809
14994
|
this.log("nextCard: queues empty, awaiting in-flight replan before drawing");
|
|
14810
14995
|
await this._replanPromise;
|
|
14811
14996
|
}
|
|
14812
|
-
if (this.
|
|
14997
|
+
if (this.supplyQ.length <= _SessionController.DEPLETION_PREFETCH_THRESHOLD && this._secondsRemaining > 0 && !this._replanPromise) {
|
|
14813
14998
|
this._suppressQualityReplan = false;
|
|
14814
|
-
const otherContent = this.reviewQ.length + this.failedQ.length;
|
|
14815
14999
|
this.log(
|
|
14816
|
-
`[AutoReplan:depletion]
|
|
15000
|
+
`[AutoReplan:depletion] supplyQ has ${this.supplyQ.length} card(s) (${this.failedQ.length} failed pending) with ${this._secondsRemaining}s remaining. Triggering background replan.`
|
|
14817
15001
|
);
|
|
14818
15002
|
void this.requestReplan({ label: "auto:depletion", mode: "merge" });
|
|
14819
15003
|
}
|
|
14820
15004
|
const REPLAN_BUFFER = 3;
|
|
14821
|
-
if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.
|
|
15005
|
+
if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.supplyQ.length > 0 && !this._replanPromise) {
|
|
14822
15006
|
this.log(
|
|
14823
|
-
`[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining (
|
|
15007
|
+
`[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining (supplyQ: ${this.supplyQ.length}). Triggering background replan.`
|
|
14824
15008
|
);
|
|
14825
15009
|
void this.requestReplan({ label: "auto:quality" });
|
|
14826
15010
|
}
|
|
@@ -14832,12 +15016,12 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14832
15016
|
const WEDGE_MAX_EMPTY_STREAK = 3;
|
|
14833
15017
|
const WEDGE_BACKOFF_MS = 250;
|
|
14834
15018
|
let wedgeEmptyStreak = 0;
|
|
14835
|
-
while (this._secondsRemaining > 0 && this.
|
|
15019
|
+
while (this._secondsRemaining > 0 && this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
14836
15020
|
this.log(
|
|
14837
15021
|
`[WedgeBreaker] All queues empty with ${this._secondsRemaining}s remaining. Running pipeline (attempt ${wedgeEmptyStreak + 1}/${WEDGE_MAX_EMPTY_STREAK}).`
|
|
14838
15022
|
);
|
|
14839
15023
|
await this._replanUncoalesced({ label: "wedge-breaker" });
|
|
14840
|
-
if (this.
|
|
15024
|
+
if (this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
14841
15025
|
wedgeEmptyStreak++;
|
|
14842
15026
|
if (wedgeEmptyStreak >= WEDGE_MAX_EMPTY_STREAK) {
|
|
14843
15027
|
this.log(
|
|
@@ -14867,15 +15051,16 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14867
15051
|
await this.hydrationService.ensureHydratedCards();
|
|
14868
15052
|
this._currentCard = card;
|
|
14869
15053
|
const origin = nextItem.status === "review" || nextItem.status === "failed-review" ? "review" : nextItem.status === "new" || nextItem.status === "failed-new" ? "new" : "failed";
|
|
14870
|
-
const queueSource = nextItem.status.startsWith("failed") ? "failedQ" :
|
|
15054
|
+
const queueSource = nextItem.status.startsWith("failed") ? "failedQ" : "supplyQ";
|
|
14871
15055
|
recordCardPresentation(
|
|
14872
15056
|
nextItem.cardID,
|
|
14873
15057
|
nextItem.courseID,
|
|
14874
15058
|
this.courseNameCache.get(nextItem.courseID),
|
|
14875
15059
|
origin,
|
|
14876
|
-
queueSource
|
|
15060
|
+
queueSource,
|
|
15061
|
+
nextItem.score
|
|
14877
15062
|
);
|
|
14878
|
-
snapshotQueues(this.
|
|
15063
|
+
snapshotQueues(this.supplyQ.length, this.failedQ.length);
|
|
14879
15064
|
return card;
|
|
14880
15065
|
}
|
|
14881
15066
|
this.log(`Skipping card ${nextItem.cardID}: hydration failed, trying next`);
|
|
@@ -14945,6 +15130,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14945
15130
|
};
|
|
14946
15131
|
}
|
|
14947
15132
|
this.failedQ.add(failedItem, failedItem.cardID);
|
|
15133
|
+
this._supplyDrawsSinceFailed = 0;
|
|
14948
15134
|
} else if (action === "dismiss-error") {
|
|
14949
15135
|
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
14950
15136
|
} else if (action === "dismiss-failed") {
|
|
@@ -14958,15 +15144,15 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14958
15144
|
removeItemFromQueue(item) {
|
|
14959
15145
|
this._clearDurableRequirement(item.cardID);
|
|
14960
15146
|
this._servedCardIds.add(item.cardID);
|
|
14961
|
-
if (this.
|
|
14962
|
-
this.
|
|
14963
|
-
|
|
14964
|
-
|
|
15147
|
+
if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
15148
|
+
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
15149
|
+
this._supplyDrawsSinceFailed = 0;
|
|
15150
|
+
} else if (this.supplyQ.peek(0)?.cardID === item.cardID) {
|
|
15151
|
+
this.supplyQ.dequeue((queueItem) => queueItem.cardID);
|
|
15152
|
+
this._supplyDrawsSinceFailed++;
|
|
14965
15153
|
if (this._wellIndicatedRemaining > 0) {
|
|
14966
15154
|
this._wellIndicatedRemaining--;
|
|
14967
15155
|
}
|
|
14968
|
-
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
14969
|
-
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
14970
15156
|
}
|
|
14971
15157
|
}
|
|
14972
15158
|
/**
|
|
@@ -15066,6 +15252,7 @@ init_factory();
|
|
|
15066
15252
|
areQuestionRecords,
|
|
15067
15253
|
buildStrategyStateId,
|
|
15068
15254
|
captureMixerRun,
|
|
15255
|
+
clearSrsBacklogDebug,
|
|
15069
15256
|
computeDeviation,
|
|
15070
15257
|
computeEffectiveWeight,
|
|
15071
15258
|
computeOutcomeSignal,
|
|
@@ -15076,6 +15263,7 @@ init_factory();
|
|
|
15076
15263
|
docIsDeleted,
|
|
15077
15264
|
endSessionTracking,
|
|
15078
15265
|
ensureAppDataDirectory,
|
|
15266
|
+
getActivePipeline,
|
|
15079
15267
|
getAppDataDirectory,
|
|
15080
15268
|
getCardHistoryID,
|
|
15081
15269
|
getCardOrigin,
|
|
@@ -15085,6 +15273,7 @@ init_factory();
|
|
|
15085
15273
|
getRegisteredNavigator,
|
|
15086
15274
|
getRegisteredNavigatorNames,
|
|
15087
15275
|
getRegisteredNavigatorRole,
|
|
15276
|
+
getSrsBacklogDebug,
|
|
15088
15277
|
getStudySource,
|
|
15089
15278
|
hasRegisteredNavigator,
|
|
15090
15279
|
importParsedCards,
|