@vue-skuilder/db 0.2.8 → 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 +29 -4
- package/dist/core/index.d.ts +29 -4
- package/dist/core/index.js +132 -39
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +130 -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 +128 -39
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +128 -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 +128 -39
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +128 -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 +371 -251
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +369 -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/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 +5 -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
|
@@ -1770,6 +1770,30 @@ Example:
|
|
|
1770
1770
|
}
|
|
1771
1771
|
});
|
|
1772
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
|
+
|
|
1773
1797
|
// src/core/navigators/generators/CompositeGenerator.ts
|
|
1774
1798
|
var CompositeGenerator_exports = {};
|
|
1775
1799
|
__export(CompositeGenerator_exports, {
|
|
@@ -2137,7 +2161,7 @@ function shuffleInPlace(arr) {
|
|
|
2137
2161
|
function pickTopByScore(cards, limit) {
|
|
2138
2162
|
return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
|
|
2139
2163
|
}
|
|
2140
|
-
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;
|
|
2141
2165
|
var init_prescribed = __esm({
|
|
2142
2166
|
"src/core/navigators/generators/prescribed.ts"() {
|
|
2143
2167
|
"use strict";
|
|
@@ -2154,6 +2178,9 @@ var init_prescribed = __esm({
|
|
|
2154
2178
|
BASE_SUPPORT_SCORE = 0.8;
|
|
2155
2179
|
DISCOVERED_SUPPORT_SCORE = 12;
|
|
2156
2180
|
BASE_PRACTICE_SCORE = 1;
|
|
2181
|
+
PRACTICE_BASE_MULT = 2;
|
|
2182
|
+
MAX_PRACTICE_MULTIPLIER = 4;
|
|
2183
|
+
PRACTICE_STALENESS_BUMP_PER_DAY = 0.5;
|
|
2157
2184
|
MAX_TARGET_MULTIPLIER = 8;
|
|
2158
2185
|
MAX_SUPPORT_MULTIPLIER = 4;
|
|
2159
2186
|
PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
|
|
@@ -2213,6 +2240,8 @@ var init_prescribed = __esm({
|
|
|
2213
2240
|
const emitted = [];
|
|
2214
2241
|
const emittedIds = /* @__PURE__ */ new Set();
|
|
2215
2242
|
const groupRuntimes = [];
|
|
2243
|
+
const priorPracticeDebt = progress.practiceDebt ?? {};
|
|
2244
|
+
const nextPracticeDebt = {};
|
|
2216
2245
|
for (const group of this.config.groups) {
|
|
2217
2246
|
const runtime = this.buildGroupRuntimeState({
|
|
2218
2247
|
group,
|
|
@@ -2270,10 +2299,13 @@ var init_prescribed = __esm({
|
|
|
2270
2299
|
userTagElo,
|
|
2271
2300
|
userGlobalElo,
|
|
2272
2301
|
activeIds,
|
|
2273
|
-
seenIds
|
|
2302
|
+
seenIds,
|
|
2303
|
+
priorPracticeDebt,
|
|
2304
|
+
nextPracticeDebt
|
|
2274
2305
|
});
|
|
2275
2306
|
emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
|
|
2276
2307
|
}
|
|
2308
|
+
nextState.practiceDebt = nextPracticeDebt;
|
|
2277
2309
|
const hintSummary = this.buildSupportHintSummary(groupRuntimes);
|
|
2278
2310
|
const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
|
|
2279
2311
|
boostTags: hintSummary.boostTags,
|
|
@@ -2607,9 +2639,16 @@ var init_prescribed = __esm({
|
|
|
2607
2639
|
* `practiceMinCount`), this resolves cards carrying that tag and emits them
|
|
2608
2640
|
* into the candidate pool. It exists because global-ELO retrieval
|
|
2609
2641
|
* systematically fails to fetch the (low-ELO) drill cards for a
|
|
2610
|
-
* freshly-introduced skill — putting them in the pool here
|
|
2611
|
-
*
|
|
2612
|
-
*
|
|
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.
|
|
2613
2652
|
*
|
|
2614
2653
|
* Fully data-driven: the unlock relation comes from the hierarchy config and
|
|
2615
2654
|
* practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
|
|
@@ -2624,7 +2663,9 @@ var init_prescribed = __esm({
|
|
|
2624
2663
|
userTagElo,
|
|
2625
2664
|
userGlobalElo,
|
|
2626
2665
|
activeIds,
|
|
2627
|
-
seenIds
|
|
2666
|
+
seenIds,
|
|
2667
|
+
priorPracticeDebt,
|
|
2668
|
+
nextPracticeDebt
|
|
2628
2669
|
} = args;
|
|
2629
2670
|
const patterns = group.practiceTagPatterns ?? [];
|
|
2630
2671
|
if (patterns.length === 0) return [];
|
|
@@ -2634,6 +2675,20 @@ var init_prescribed = __esm({
|
|
|
2634
2675
|
(tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
|
|
2635
2676
|
);
|
|
2636
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
|
+
}
|
|
2637
2692
|
const practiceCardIds = this.findDiscoveredSupportCards({
|
|
2638
2693
|
supportTags: practiceTags,
|
|
2639
2694
|
cardsByTag,
|
|
@@ -2649,18 +2704,25 @@ var init_prescribed = __esm({
|
|
|
2649
2704
|
const cards = [];
|
|
2650
2705
|
for (const cardId of practiceCardIds) {
|
|
2651
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;
|
|
2652
2714
|
cards.push({
|
|
2653
2715
|
cardId,
|
|
2654
2716
|
courseId,
|
|
2655
|
-
score
|
|
2717
|
+
score,
|
|
2656
2718
|
provenance: [
|
|
2657
2719
|
{
|
|
2658
2720
|
strategy: "prescribed",
|
|
2659
2721
|
strategyName: this.strategyName || this.name,
|
|
2660
2722
|
strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
|
|
2661
2723
|
action: "generated",
|
|
2662
|
-
score
|
|
2663
|
-
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}`
|
|
2664
2726
|
}
|
|
2665
2727
|
]
|
|
2666
2728
|
});
|
|
@@ -2834,14 +2896,15 @@ __export(srs_exports, {
|
|
|
2834
2896
|
default: () => SRSNavigator
|
|
2835
2897
|
});
|
|
2836
2898
|
import moment3 from "moment";
|
|
2837
|
-
var DEFAULT_HEALTHY_BACKLOG,
|
|
2899
|
+
var DEFAULT_HEALTHY_BACKLOG, MAX_BACKLOG_MULTIPLIER, SRSNavigator;
|
|
2838
2900
|
var init_srs = __esm({
|
|
2839
2901
|
"src/core/navigators/generators/srs.ts"() {
|
|
2840
2902
|
"use strict";
|
|
2841
2903
|
init_navigators();
|
|
2904
|
+
init_SrsDebugger();
|
|
2842
2905
|
init_logger();
|
|
2843
2906
|
DEFAULT_HEALTHY_BACKLOG = 20;
|
|
2844
|
-
|
|
2907
|
+
MAX_BACKLOG_MULTIPLIER = 2;
|
|
2845
2908
|
SRSNavigator = class extends ContentNavigator {
|
|
2846
2909
|
/** Human-readable name for CardGenerator interface */
|
|
2847
2910
|
name;
|
|
@@ -2908,9 +2971,18 @@ var init_srs = __esm({
|
|
|
2908
2971
|
}
|
|
2909
2972
|
}
|
|
2910
2973
|
}
|
|
2911
|
-
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
|
+
}
|
|
2912
2984
|
if (dueReviews.length > 0) {
|
|
2913
|
-
const pressureNote =
|
|
2985
|
+
const pressureNote = backlogMultiplier > 1 ? ` [backlog pressure: \xD7${backlogMultiplier.toFixed(2)}]` : ` [healthy backlog]`;
|
|
2914
2986
|
logger.info(
|
|
2915
2987
|
`[SRS] Course ${courseId}: ${dueReviews.length} reviews due now (of ${reviews.length} scheduled)${pressureNote}`
|
|
2916
2988
|
);
|
|
@@ -2929,7 +3001,7 @@ var init_srs = __esm({
|
|
|
2929
3001
|
logger.info(`[SRS] Course ${courseId}: No reviews scheduled`);
|
|
2930
3002
|
}
|
|
2931
3003
|
const scored = dueReviews.map((review) => {
|
|
2932
|
-
const { score, reason } = this.computeUrgencyScore(review, now,
|
|
3004
|
+
const { score, reason } = this.computeUrgencyScore(review, now, backlogMultiplier);
|
|
2933
3005
|
return {
|
|
2934
3006
|
cardId: review.cardId,
|
|
2935
3007
|
courseId: review.courseId,
|
|
@@ -2947,30 +3019,42 @@ var init_srs = __esm({
|
|
|
2947
3019
|
]
|
|
2948
3020
|
};
|
|
2949
3021
|
});
|
|
2950
|
-
|
|
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) };
|
|
2951
3035
|
}
|
|
2952
3036
|
/**
|
|
2953
|
-
* Compute backlog pressure based on number of due reviews.
|
|
3037
|
+
* Compute the multiplicative backlog pressure based on number of due reviews.
|
|
2954
3038
|
*
|
|
2955
|
-
*
|
|
2956
|
-
* 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.
|
|
2957
3041
|
*
|
|
2958
|
-
* Examples (with default healthyBacklog=20):
|
|
2959
|
-
* - 10 due reviews →
|
|
2960
|
-
* - 20 due reviews →
|
|
2961
|
-
* - 40 due reviews →
|
|
2962
|
-
* - 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)
|
|
2963
3047
|
*
|
|
2964
3048
|
* @param dueCount - Number of reviews currently due
|
|
2965
|
-
* @returns
|
|
3049
|
+
* @returns Multiplier applied to review urgency (1.0 to MAX_BACKLOG_MULTIPLIER)
|
|
2966
3050
|
*/
|
|
2967
|
-
|
|
3051
|
+
computeBacklogMultiplier(dueCount) {
|
|
2968
3052
|
if (dueCount <= this.healthyBacklog) {
|
|
2969
|
-
return
|
|
3053
|
+
return 1;
|
|
2970
3054
|
}
|
|
2971
3055
|
const excess = dueCount - this.healthyBacklog;
|
|
2972
|
-
const
|
|
2973
|
-
return Math.min(
|
|
3056
|
+
const multiplier = 1 + excess / this.healthyBacklog * ((MAX_BACKLOG_MULTIPLIER - 1) / 2);
|
|
3057
|
+
return Math.min(MAX_BACKLOG_MULTIPLIER, multiplier);
|
|
2974
3058
|
}
|
|
2975
3059
|
/**
|
|
2976
3060
|
* Compute urgency score for a review card.
|
|
@@ -2985,19 +3069,20 @@ var init_srs = __esm({
|
|
|
2985
3069
|
* - 30 days (720h) → ~0.56
|
|
2986
3070
|
* - 180 days → ~0.30
|
|
2987
3071
|
*
|
|
2988
|
-
* 3. Backlog pressure = global
|
|
2989
|
-
*
|
|
2990
|
-
* - At 2x healthy: +0.25
|
|
2991
|
-
* - 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×).
|
|
2992
3074
|
*
|
|
2993
|
-
* Combined: base 0.5 +
|
|
2994
|
-
*
|
|
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.
|
|
2995
3080
|
*
|
|
2996
3081
|
* @param review - The scheduled card to score
|
|
2997
3082
|
* @param now - Current time
|
|
2998
|
-
* @param
|
|
3083
|
+
* @param backlogMultiplier - Pre-computed backlog multiplier (1.0 to MAX_BACKLOG_MULTIPLIER)
|
|
2999
3084
|
*/
|
|
3000
|
-
computeUrgencyScore(review, now,
|
|
3085
|
+
computeUrgencyScore(review, now, backlogMultiplier) {
|
|
3001
3086
|
const scheduledAt = moment3.utc(review.scheduledAt);
|
|
3002
3087
|
const due = moment3.utc(review.reviewTime);
|
|
3003
3088
|
const intervalHours = Math.max(1, due.diff(scheduledAt, "hours"));
|
|
@@ -3007,15 +3092,15 @@ var init_srs = __esm({
|
|
|
3007
3092
|
const overdueContribution = Math.min(1, Math.max(0, relativeOverdue));
|
|
3008
3093
|
const urgency = overdueContribution * 0.5 + recencyFactor * 0.5;
|
|
3009
3094
|
const baseScore = 0.5 + urgency * 0.45;
|
|
3010
|
-
const score =
|
|
3095
|
+
const score = baseScore * backlogMultiplier;
|
|
3011
3096
|
const reasonParts = [
|
|
3012
3097
|
`${Math.round(hoursOverdue)}h overdue`,
|
|
3013
3098
|
`interval: ${Math.round(intervalHours)}h`,
|
|
3014
3099
|
`relative: ${relativeOverdue.toFixed(2)}`,
|
|
3015
3100
|
`recency: ${recencyFactor.toFixed(2)}`
|
|
3016
3101
|
];
|
|
3017
|
-
if (
|
|
3018
|
-
reasonParts.push(`backlog:
|
|
3102
|
+
if (backlogMultiplier > 1) {
|
|
3103
|
+
reasonParts.push(`backlog: \xD7${backlogMultiplier.toFixed(2)}`);
|
|
3019
3104
|
}
|
|
3020
3105
|
reasonParts.push("review");
|
|
3021
3106
|
const reason = reasonParts.join(", ");
|
|
@@ -5313,6 +5398,7 @@ var init_3 = __esm({
|
|
|
5313
5398
|
"./Pipeline.ts": () => Promise.resolve().then(() => (init_Pipeline(), Pipeline_exports)),
|
|
5314
5399
|
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
5315
5400
|
"./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
|
|
5401
|
+
"./SrsDebugger.ts": () => Promise.resolve().then(() => (init_SrsDebugger(), SrsDebugger_exports)),
|
|
5316
5402
|
"./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
|
|
5317
5403
|
"./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
|
|
5318
5404
|
"./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
|
|
@@ -5345,12 +5431,14 @@ __export(navigators_exports, {
|
|
|
5345
5431
|
NavigatorRole: () => NavigatorRole,
|
|
5346
5432
|
NavigatorRoles: () => NavigatorRoles,
|
|
5347
5433
|
Navigators: () => Navigators,
|
|
5434
|
+
clearSrsBacklogDebug: () => clearSrsBacklogDebug,
|
|
5348
5435
|
diversityRerank: () => diversityRerank,
|
|
5349
5436
|
getActivePipeline: () => getActivePipeline,
|
|
5350
5437
|
getCardOrigin: () => getCardOrigin,
|
|
5351
5438
|
getRegisteredNavigator: () => getRegisteredNavigator,
|
|
5352
5439
|
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
5353
5440
|
getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
|
|
5441
|
+
getSrsBacklogDebug: () => getSrsBacklogDebug,
|
|
5354
5442
|
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
5355
5443
|
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
5356
5444
|
isFilter: () => isFilter,
|
|
@@ -5432,6 +5520,7 @@ var init_navigators = __esm({
|
|
|
5432
5520
|
"use strict";
|
|
5433
5521
|
init_diversityRerank();
|
|
5434
5522
|
init_PipelineDebugger();
|
|
5523
|
+
init_SrsDebugger();
|
|
5435
5524
|
init_logger();
|
|
5436
5525
|
init_();
|
|
5437
5526
|
init_2();
|
|
@@ -13338,6 +13427,7 @@ mountMixerDebugger();
|
|
|
13338
13427
|
// src/study/SessionDebugger.ts
|
|
13339
13428
|
init_logger();
|
|
13340
13429
|
init_PipelineDebugger();
|
|
13430
|
+
init_SrsDebugger();
|
|
13341
13431
|
|
|
13342
13432
|
// src/study/SessionOverlay.ts
|
|
13343
13433
|
init_logger();
|
|
@@ -13359,8 +13449,7 @@ var lastSnapshot = null;
|
|
|
13359
13449
|
var copyFlashUntil = 0;
|
|
13360
13450
|
var minified = false;
|
|
13361
13451
|
var expanded = {
|
|
13362
|
-
|
|
13363
|
-
newQ: false,
|
|
13452
|
+
supplyQ: false,
|
|
13364
13453
|
failedQ: false,
|
|
13365
13454
|
drawn: false
|
|
13366
13455
|
};
|
|
@@ -13429,7 +13518,7 @@ function render() {
|
|
|
13429
13518
|
attachHandlers();
|
|
13430
13519
|
return;
|
|
13431
13520
|
}
|
|
13432
|
-
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);
|
|
13433
13522
|
attachHandlers();
|
|
13434
13523
|
}
|
|
13435
13524
|
function attachHandlers() {
|
|
@@ -13521,6 +13610,29 @@ function hintsHtml(h) {
|
|
|
13521
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>`;
|
|
13522
13611
|
return `<div style="margin-bottom:6px"><div style="color:#86efac">sessionHints</div>${body}</div>`;
|
|
13523
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
|
+
}
|
|
13524
13636
|
function queueHtml(key, label, q) {
|
|
13525
13637
|
const collapsible = q.length > INLINE_THRESHOLD;
|
|
13526
13638
|
const isOpen = collapsible && expanded[key];
|
|
@@ -13535,7 +13647,7 @@ function queueHtml(key, label, q) {
|
|
|
13535
13647
|
const shown = isOpen ? q.cards : q.cards.slice(0, INLINE_THRESHOLD);
|
|
13536
13648
|
const hiddenCount = q.length - shown.length;
|
|
13537
13649
|
const listMarginBottom = collapsible ? 2 : 6;
|
|
13538
|
-
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>`;
|
|
13539
13651
|
if (collapsible) {
|
|
13540
13652
|
const footer = isOpen ? "\u25BE show less" : `\u2026 +${hiddenCount} more`;
|
|
13541
13653
|
body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
|
|
@@ -13598,13 +13710,29 @@ function snapshotToText(s) {
|
|
|
13598
13710
|
if (h.excludeCards?.length) hintParts.push(` excludeCards: ${h.excludeCards.join(", ")}`);
|
|
13599
13711
|
}
|
|
13600
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
|
+
}
|
|
13601
13726
|
const queueText = (label, q) => {
|
|
13602
13727
|
lines.push("");
|
|
13603
13728
|
lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
|
|
13604
|
-
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
|
+
});
|
|
13605
13734
|
};
|
|
13606
|
-
queueText("
|
|
13607
|
-
queueText("newQ", s.newQ);
|
|
13735
|
+
queueText("supplyQ", s.supplyQ);
|
|
13608
13736
|
queueText("failedQ", s.failedQ);
|
|
13609
13737
|
lines.push("");
|
|
13610
13738
|
lines.push(`drawn: ${s.drawnCards.length}`);
|
|
@@ -13629,16 +13757,16 @@ function esc(value) {
|
|
|
13629
13757
|
var activeSession = null;
|
|
13630
13758
|
var sessionHistory = [];
|
|
13631
13759
|
var MAX_HISTORY = 5;
|
|
13632
|
-
function startSessionTracking(
|
|
13760
|
+
function startSessionTracking(supplyQLength, failedQLength) {
|
|
13633
13761
|
clearRunHistory();
|
|
13762
|
+
clearSrsBacklogDebug();
|
|
13634
13763
|
const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
13635
13764
|
activeSession = {
|
|
13636
13765
|
sessionId,
|
|
13637
13766
|
startTime: /* @__PURE__ */ new Date(),
|
|
13638
13767
|
initialQueues: {
|
|
13639
13768
|
timestamp: /* @__PURE__ */ new Date(),
|
|
13640
|
-
|
|
13641
|
-
newQLength,
|
|
13769
|
+
supplyQLength,
|
|
13642
13770
|
failedQLength
|
|
13643
13771
|
},
|
|
13644
13772
|
presentations: [],
|
|
@@ -13662,17 +13790,15 @@ function recordCardPresentation(cardId, courseId, courseName, origin, queueSourc
|
|
|
13662
13790
|
score
|
|
13663
13791
|
});
|
|
13664
13792
|
}
|
|
13665
|
-
function snapshotQueues(
|
|
13793
|
+
function snapshotQueues(supplyQLength, failedQLength, supplyQNext3) {
|
|
13666
13794
|
if (!activeSession) {
|
|
13667
13795
|
return;
|
|
13668
13796
|
}
|
|
13669
13797
|
activeSession.queueSnapshots.push({
|
|
13670
13798
|
timestamp: /* @__PURE__ */ new Date(),
|
|
13671
|
-
|
|
13672
|
-
newQLength,
|
|
13799
|
+
supplyQLength,
|
|
13673
13800
|
failedQLength,
|
|
13674
|
-
|
|
13675
|
-
newQNext3
|
|
13801
|
+
supplyQNext3
|
|
13676
13802
|
});
|
|
13677
13803
|
}
|
|
13678
13804
|
function endSessionTracking() {
|
|
@@ -13694,13 +13820,9 @@ function showCurrentQueue() {
|
|
|
13694
13820
|
}
|
|
13695
13821
|
const latest = activeSession.queueSnapshots[activeSession.queueSnapshots.length - 1] || activeSession.initialQueues;
|
|
13696
13822
|
console.group("\u{1F4CA} Current Queue State");
|
|
13697
|
-
logger.info(`
|
|
13698
|
-
if (latest.
|
|
13699
|
-
logger.info(` Next: ${latest.
|
|
13700
|
-
}
|
|
13701
|
-
logger.info(`New Queue: ${latest.newQLength} cards`);
|
|
13702
|
-
if (latest.newQNext3 && latest.newQNext3.length > 0) {
|
|
13703
|
-
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(", ")}`);
|
|
13704
13826
|
}
|
|
13705
13827
|
logger.info(`Failed Queue: ${latest.failedQLength} cards`);
|
|
13706
13828
|
console.groupEnd();
|
|
@@ -13917,15 +14039,6 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13917
14039
|
* Individual replans can override via `ReplanOptions.limit`.
|
|
13918
14040
|
*/
|
|
13919
14041
|
_defaultBatchLimit = 20;
|
|
13920
|
-
/**
|
|
13921
|
-
* Maximum number of reviews enqueued at session start. Reviews live
|
|
13922
|
-
* outside the replan flow — the queue drains via consumption and is
|
|
13923
|
-
* not refilled mid-session. The session timer caps total review
|
|
13924
|
-
* exposure, so overfilling here is intentional. Default is generous
|
|
13925
|
-
* to accommodate Anki-style power users with hundreds of due reviews;
|
|
13926
|
-
* apps targeting nimbler sessions should override via constructor.
|
|
13927
|
-
*/
|
|
13928
|
-
_initialReviewCap = 200;
|
|
13929
14042
|
sources;
|
|
13930
14043
|
// dataLayer and getViewComponent now injected into CardHydrationService
|
|
13931
14044
|
_sessionRecord = [];
|
|
@@ -13934,10 +14047,28 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13934
14047
|
}
|
|
13935
14048
|
// Session card stores
|
|
13936
14049
|
_currentCard = null;
|
|
13937
|
-
|
|
13938
|
-
|
|
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();
|
|
13939
14059
|
failedQ = new ItemQueue();
|
|
13940
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;
|
|
13941
14072
|
/**
|
|
13942
14073
|
* Promise tracking a currently in-progress replan, or null if idle.
|
|
13943
14074
|
* Used by nextCard() to await completion before drawing from queues.
|
|
@@ -13951,8 +14082,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13951
14082
|
*/
|
|
13952
14083
|
_activeReplanLabel = null;
|
|
13953
14084
|
/**
|
|
13954
|
-
* Number of well-indicated
|
|
13955
|
-
* 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
|
|
13956
14087
|
* draw; when it hits 0, a replan is triggered automatically
|
|
13957
14088
|
* (user state has changed from completing good cards).
|
|
13958
14089
|
*/
|
|
@@ -13961,7 +14092,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13961
14092
|
* When true, suppresses the quality-based auto-replan trigger in
|
|
13962
14093
|
* nextCard(). Set after a burst replan (small limit) to prevent the
|
|
13963
14094
|
* auto-replan from clobbering the burst cards before they're consumed.
|
|
13964
|
-
* Cleared when the depletion-triggered replan fires (
|
|
14095
|
+
* Cleared when the depletion-triggered replan fires (supplyQ exhausted).
|
|
13965
14096
|
*/
|
|
13966
14097
|
_suppressQualityReplan = false;
|
|
13967
14098
|
/**
|
|
@@ -13990,13 +14121,15 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
13990
14121
|
* a draw the instant it happens — earlier than `_sessionRecord`, which only
|
|
13991
14122
|
* lands once the card is *responded to*.
|
|
13992
14123
|
*
|
|
13993
|
-
* Used to keep already-served cards out of
|
|
13994
|
-
* card shown once must never re-enter
|
|
13995
|
-
*
|
|
13996
|
-
*
|
|
13997
|
-
*
|
|
13998
|
-
*
|
|
13999
|
-
*
|
|
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.
|
|
14000
14133
|
*/
|
|
14001
14134
|
_servedCardIds = /* @__PURE__ */ new Set();
|
|
14002
14135
|
/**
|
|
@@ -14021,14 +14154,12 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14021
14154
|
return this._minCardsGuarantee > 0;
|
|
14022
14155
|
}
|
|
14023
14156
|
get report() {
|
|
14024
|
-
const
|
|
14025
|
-
const
|
|
14026
|
-
|
|
14027
|
-
const newCardWord = newCount === 1 ? "new card" : "new cards";
|
|
14028
|
-
return `${reviewCount} ${reviewWord}, ${newCount} ${newCardWord}`;
|
|
14157
|
+
const supplyCount = this.supplyQ.dequeueCount;
|
|
14158
|
+
const supplyWord = supplyCount === 1 ? "card" : "cards";
|
|
14159
|
+
return `${supplyCount} supply ${supplyWord} drawn`;
|
|
14029
14160
|
}
|
|
14030
14161
|
get detailedReport() {
|
|
14031
|
-
return this.
|
|
14162
|
+
return this.supplyQ.toString + "\n" + this.failedQ.toString;
|
|
14032
14163
|
}
|
|
14033
14164
|
// @ts-expect-error NodeJS.Timeout type not available in browser context
|
|
14034
14165
|
_intervalHandle;
|
|
@@ -14039,11 +14170,9 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14039
14170
|
* @param getViewComponent - Function to resolve view components
|
|
14040
14171
|
* @param mixer - Optional source mixer strategy (defaults to QuotaRoundRobinMixer)
|
|
14041
14172
|
* @param options - Optional session-level configuration
|
|
14042
|
-
* @param options.defaultBatchLimit - Default
|
|
14173
|
+
* @param options.defaultBatchLimit - Default supply working-set size (default: 20).
|
|
14043
14174
|
* Smaller values for newer users cause more frequent replans, keeping plans
|
|
14044
14175
|
* aligned with rapidly-changing user state.
|
|
14045
|
-
* @param options.initialReviewCap - Max reviews loaded at session start (default: 200).
|
|
14046
|
-
* Applied only on initial planning; replans do not refill the review queue.
|
|
14047
14176
|
*/
|
|
14048
14177
|
constructor(sources, time, dataLayer, getViewComponent, mixer, options) {
|
|
14049
14178
|
super();
|
|
@@ -14066,17 +14195,13 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14066
14195
|
if (options?.defaultBatchLimit !== void 0) {
|
|
14067
14196
|
this._defaultBatchLimit = options.defaultBatchLimit;
|
|
14068
14197
|
}
|
|
14069
|
-
if (options?.initialReviewCap !== void 0) {
|
|
14070
|
-
this._initialReviewCap = options.initialReviewCap;
|
|
14071
|
-
}
|
|
14072
14198
|
if (options?.outcomeObservers?.length) {
|
|
14073
14199
|
this._outcomeObservers = [...options.outcomeObservers];
|
|
14074
14200
|
}
|
|
14075
14201
|
this.log(`Session constructed:
|
|
14076
14202
|
startTime: ${this.startTime}
|
|
14077
14203
|
endTime: ${this.endTime}
|
|
14078
|
-
defaultBatchLimit: ${this._defaultBatchLimit}
|
|
14079
|
-
initialReviewCap: ${this._initialReviewCap}`);
|
|
14204
|
+
defaultBatchLimit: ${this._defaultBatchLimit}`);
|
|
14080
14205
|
registerActiveController(this);
|
|
14081
14206
|
}
|
|
14082
14207
|
tick() {
|
|
@@ -14110,15 +14235,6 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14110
14235
|
this.log(`Failed card cleanup estimate: ${Math.round(ret)}`);
|
|
14111
14236
|
return ret;
|
|
14112
14237
|
}
|
|
14113
|
-
/**
|
|
14114
|
-
* Extremely rough, conservative, estimate of amound of time to complete
|
|
14115
|
-
* all scheduled reviews
|
|
14116
|
-
*/
|
|
14117
|
-
estimateReviewTime() {
|
|
14118
|
-
const ret = 5 * this.reviewQ.length;
|
|
14119
|
-
this.log(`Review card time estimate: ${ret}`);
|
|
14120
|
-
return ret;
|
|
14121
|
-
}
|
|
14122
14238
|
async prepareSession() {
|
|
14123
14239
|
if (this.sources.some((s) => typeof s.getWeightedCards !== "function")) {
|
|
14124
14240
|
throw new Error(
|
|
@@ -14133,15 +14249,15 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14133
14249
|
);
|
|
14134
14250
|
}
|
|
14135
14251
|
await this.hydrationService.ensureHydratedCards();
|
|
14136
|
-
startSessionTracking(this.
|
|
14252
|
+
startSessionTracking(this.supplyQ.length, this.failedQ.length);
|
|
14137
14253
|
this._intervalHandle = setInterval(() => {
|
|
14138
14254
|
this.tick();
|
|
14139
14255
|
}, 1e3);
|
|
14140
14256
|
}
|
|
14141
14257
|
/**
|
|
14142
14258
|
* Request a mid-session replan. Re-runs the pipeline with current user state
|
|
14143
|
-
* and atomically replaces the
|
|
14144
|
-
* a session.
|
|
14259
|
+
* and atomically replaces (or merges into) the supplyQ contents. Safe to call
|
|
14260
|
+
* at any time during a session.
|
|
14145
14261
|
*
|
|
14146
14262
|
* Concurrency policy:
|
|
14147
14263
|
* - Two unhinted auto-replans never run in parallel; the second coalesces
|
|
@@ -14155,7 +14271,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14155
14271
|
* results (e.g. surfacing another gpc-intro card right after one
|
|
14156
14272
|
* completed, skipping the prescribed `c-wst-*` follow-up).
|
|
14157
14273
|
*
|
|
14158
|
-
*
|
|
14274
|
+
* Re-pulls and re-ranks the whole supply (including reviews); does NOT affect
|
|
14275
|
+
* failedQ (controller-owned remediation).
|
|
14159
14276
|
*
|
|
14160
14277
|
* If nextCard() is called while a replan is in flight, it will automatically
|
|
14161
14278
|
* await the replan before drawing from queues, ensuring the user always sees
|
|
@@ -14221,7 +14338,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14221
14338
|
* excludeCards happen at *invocation* time, not at *queue* time. For a
|
|
14222
14339
|
* queued replan that means excludes reflect the state after the prior
|
|
14223
14340
|
* replan landed — which is what we want, since the prior replan's
|
|
14224
|
-
*
|
|
14341
|
+
* supplyQ.peek(0) is the imminent draw we need to exclude.
|
|
14225
14342
|
*/
|
|
14226
14343
|
async _runReplan(opts) {
|
|
14227
14344
|
this._activeReplanLabel = opts.label ?? "(auto)";
|
|
@@ -14234,8 +14351,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14234
14351
|
for (const rec of this._sessionRecord) {
|
|
14235
14352
|
excludeSet.add(rec.card.card_id);
|
|
14236
14353
|
}
|
|
14237
|
-
if (this.
|
|
14238
|
-
excludeSet.add(this.
|
|
14354
|
+
if (this.supplyQ.length > 0) {
|
|
14355
|
+
excludeSet.add(this.supplyQ.peek(0).cardID);
|
|
14239
14356
|
}
|
|
14240
14357
|
hints.excludeCards = [...excludeSet];
|
|
14241
14358
|
if (opts.sessionHints !== void 0) {
|
|
@@ -14296,7 +14413,13 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14296
14413
|
const describe = (q) => {
|
|
14297
14414
|
const cards = [];
|
|
14298
14415
|
for (let i = 0; i < q.length; i++) {
|
|
14299
|
-
|
|
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
|
+
});
|
|
14300
14423
|
}
|
|
14301
14424
|
return { length: q.length, dequeueCount: q.dequeueCount, cards };
|
|
14302
14425
|
};
|
|
@@ -14319,9 +14442,9 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14319
14442
|
sessionHints: this._sessionHints,
|
|
14320
14443
|
replanActive: this._replanPromise !== null,
|
|
14321
14444
|
replanLabel: this._activeReplanLabel,
|
|
14322
|
-
|
|
14323
|
-
newQ: describe(this.newQ),
|
|
14445
|
+
supplyQ: describe(this.supplyQ),
|
|
14324
14446
|
failedQ: describe(this.failedQ),
|
|
14447
|
+
reviewBacklog: getSrsBacklogDebug(),
|
|
14325
14448
|
drawnCards
|
|
14326
14449
|
};
|
|
14327
14450
|
}
|
|
@@ -14441,7 +14564,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14441
14564
|
*/
|
|
14442
14565
|
static WELL_INDICATED_SCORE = 0.1;
|
|
14443
14566
|
/**
|
|
14444
|
-
*
|
|
14567
|
+
* supplyQ length at or below which the opportunistic depletion-prefetch
|
|
14445
14568
|
* fires. Sets the lead time available for the background replan to land
|
|
14446
14569
|
* before the user actually empties the queue and falls into the
|
|
14447
14570
|
* (synchronous) wedge-breaker path.
|
|
@@ -14454,7 +14577,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14454
14577
|
*/
|
|
14455
14578
|
static DEPLETION_PREFETCH_THRESHOLD = 3;
|
|
14456
14579
|
/**
|
|
14457
|
-
* Internal replan execution. Runs the pipeline,
|
|
14580
|
+
* Internal replan execution. Runs the pipeline, rebuilds the supplyQ,
|
|
14458
14581
|
* atomically swaps it in, and triggers hydration for the new contents.
|
|
14459
14582
|
*
|
|
14460
14583
|
* If the initial replan produces fewer than MIN_WELL_INDICATED cards that
|
|
@@ -14483,8 +14606,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14483
14606
|
}
|
|
14484
14607
|
await this.hydrationService.ensureHydratedCards();
|
|
14485
14608
|
const labelTag = opts.label ? ` [${opts.label}]` : "";
|
|
14486
|
-
this.log(`Replan complete${labelTag}:
|
|
14487
|
-
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);
|
|
14488
14611
|
}
|
|
14489
14612
|
addTime(seconds) {
|
|
14490
14613
|
this.endTime = new Date(this.endTime.valueOf() + 1e3 * seconds);
|
|
@@ -14493,10 +14616,10 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14493
14616
|
return this.failedQ.length;
|
|
14494
14617
|
}
|
|
14495
14618
|
toString() {
|
|
14496
|
-
return `Session: ${this.
|
|
14619
|
+
return `Session: ${this.supplyQ.length} supply, ${this.failedQ.length} failed`;
|
|
14497
14620
|
}
|
|
14498
14621
|
reportString() {
|
|
14499
|
-
return `${this.
|
|
14622
|
+
return `${this.supplyQ.dequeueCount} supply, ${this.failedQ.dequeueCount} failed`;
|
|
14500
14623
|
}
|
|
14501
14624
|
/**
|
|
14502
14625
|
* Returns debug information about the current session state.
|
|
@@ -14513,7 +14636,8 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14513
14636
|
items.push({
|
|
14514
14637
|
courseID: item.courseID || "unknown",
|
|
14515
14638
|
cardID: item.cardID || "unknown",
|
|
14516
|
-
status: item.status || "unknown"
|
|
14639
|
+
status: item.status || "unknown",
|
|
14640
|
+
score: item.score
|
|
14517
14641
|
});
|
|
14518
14642
|
}
|
|
14519
14643
|
return items;
|
|
@@ -14523,15 +14647,10 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14523
14647
|
mode: supportsWeightedCards ? "weighted" : "legacy",
|
|
14524
14648
|
description: supportsWeightedCards ? "Using getWeightedCards() API with scored candidates" : "ERROR: getWeightedCards() not a function."
|
|
14525
14649
|
},
|
|
14526
|
-
|
|
14527
|
-
length: this.
|
|
14528
|
-
dequeueCount: this.
|
|
14529
|
-
items: extractQueueItems(this.
|
|
14530
|
-
},
|
|
14531
|
-
newQueue: {
|
|
14532
|
-
length: this.newQ.length,
|
|
14533
|
-
dequeueCount: this.newQ.dequeueCount,
|
|
14534
|
-
items: extractQueueItems(this.newQ)
|
|
14650
|
+
supplyQueue: {
|
|
14651
|
+
length: this.supplyQ.length,
|
|
14652
|
+
dequeueCount: this.supplyQ.dequeueCount,
|
|
14653
|
+
items: extractQueueItems(this.supplyQ)
|
|
14535
14654
|
},
|
|
14536
14655
|
failedQueue: {
|
|
14537
14656
|
length: this.failedQ.length,
|
|
@@ -14551,30 +14670,29 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14551
14670
|
};
|
|
14552
14671
|
}
|
|
14553
14672
|
/**
|
|
14554
|
-
* Fetch content
|
|
14673
|
+
* Fetch weighted content from all sources, mix across sources, and populate
|
|
14674
|
+
* the single supply queue in pipeline rank order.
|
|
14555
14675
|
*
|
|
14556
|
-
*
|
|
14557
|
-
* 1.
|
|
14558
|
-
*
|
|
14559
|
-
*
|
|
14560
|
-
*
|
|
14561
|
-
|
|
14562
|
-
/**
|
|
14563
|
-
* 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.
|
|
14564
14682
|
*
|
|
14565
14683
|
* @param options.replan - If true, this is a mid-session replan rather than
|
|
14566
|
-
* initial session setup.
|
|
14567
|
-
*
|
|
14568
|
-
* @param options.additive - If true (replan only), merge
|
|
14569
|
-
* 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.
|
|
14570
14688
|
* @returns Number of "well-indicated" cards (passed all hierarchy filters)
|
|
14571
14689
|
* in the new content. Returns -1 if no content was loaded.
|
|
14572
14690
|
*/
|
|
14573
14691
|
async getWeightedContent(options) {
|
|
14574
14692
|
const replan = options?.replan ?? false;
|
|
14575
14693
|
const additive = options?.additive ?? false;
|
|
14576
|
-
const
|
|
14577
|
-
const fetchLimit =
|
|
14694
|
+
const supplyLimit = options?.limit ?? this._defaultBatchLimit;
|
|
14695
|
+
const fetchLimit = supplyLimit;
|
|
14578
14696
|
if (!replan) {
|
|
14579
14697
|
this._applyHintsToSources();
|
|
14580
14698
|
}
|
|
@@ -14596,7 +14714,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14596
14714
|
}
|
|
14597
14715
|
if (batches.length === 0) {
|
|
14598
14716
|
if (replan) {
|
|
14599
|
-
this.log("Replan: no content from any source, keeping existing
|
|
14717
|
+
this.log("Replan: no content from any source, keeping existing supplyQ");
|
|
14600
14718
|
return -1;
|
|
14601
14719
|
}
|
|
14602
14720
|
throw new Error(
|
|
@@ -14630,64 +14748,59 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14630
14748
|
quotaPerSource,
|
|
14631
14749
|
mixedWeighted
|
|
14632
14750
|
);
|
|
14633
|
-
const
|
|
14634
|
-
const
|
|
14635
|
-
|
|
14636
|
-
|
|
14637
|
-
const mandatoryWeighted = newCandidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
|
|
14638
|
-
const optionalWeighted = newCandidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
|
|
14639
|
-
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 = [
|
|
14640
14755
|
...mandatoryWeighted,
|
|
14641
|
-
...optionalWeighted.slice(0, Math.max(0,
|
|
14756
|
+
...optionalWeighted.slice(0, Math.max(0, supplyLimit - mandatoryWeighted.length))
|
|
14642
14757
|
];
|
|
14643
14758
|
const mandatoryIds = new Set(mandatoryWeighted.map((w) => w.cardId));
|
|
14644
|
-
|
|
14645
|
-
let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
|
|
14646
|
-
if (!replan) {
|
|
14647
|
-
for (const w of reviewWeighted) {
|
|
14648
|
-
const reviewItem = {
|
|
14649
|
-
cardID: w.cardId,
|
|
14650
|
-
courseID: w.courseId,
|
|
14651
|
-
contentSourceType: "course",
|
|
14652
|
-
contentSourceID: w.courseId,
|
|
14653
|
-
reviewID: w.reviewID,
|
|
14654
|
-
status: "review"
|
|
14655
|
-
};
|
|
14656
|
-
this.reviewQ.add(reviewItem, reviewItem.cardID);
|
|
14657
|
-
report += `Review: ${w.courseId}::${w.cardId} (score: ${w.score.toFixed(2)})
|
|
14658
|
-
`;
|
|
14659
|
-
}
|
|
14660
|
-
}
|
|
14661
|
-
const wellIndicated = newWeighted.filter(
|
|
14759
|
+
const wellIndicated = supplyWeighted.filter(
|
|
14662
14760
|
(w) => w.score >= _SessionController.WELL_INDICATED_SCORE
|
|
14663
14761
|
).length;
|
|
14664
|
-
|
|
14665
|
-
|
|
14666
|
-
const
|
|
14667
|
-
|
|
14668
|
-
|
|
14669
|
-
contentSourceType: "course",
|
|
14670
|
-
contentSourceID: w.courseId,
|
|
14671
|
-
status: "new"
|
|
14672
|
-
};
|
|
14673
|
-
newItems.push(newItem);
|
|
14674
|
-
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})
|
|
14675
14767
|
`;
|
|
14676
|
-
|
|
14768
|
+
return this._buildSupplyItem(w, origin);
|
|
14769
|
+
});
|
|
14677
14770
|
if (additive) {
|
|
14678
|
-
const added = this.
|
|
14679
|
-
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
|
|
14680
14773
|
`;
|
|
14681
14774
|
} else if (replan) {
|
|
14682
|
-
this.
|
|
14775
|
+
this.supplyQ.replaceAll(supplyItems, (item) => item.cardID);
|
|
14683
14776
|
} else {
|
|
14684
|
-
for (const item of
|
|
14685
|
-
this.
|
|
14777
|
+
for (const item of supplyItems) {
|
|
14778
|
+
this.supplyQ.add(item, item.cardID);
|
|
14686
14779
|
}
|
|
14687
14780
|
}
|
|
14688
14781
|
this.log(report);
|
|
14689
14782
|
return wellIndicated;
|
|
14690
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
|
+
}
|
|
14691
14804
|
/**
|
|
14692
14805
|
* Returns items that should be pre-hydrated.
|
|
14693
14806
|
* Deterministic: top N items from each queue to ensure coverage.
|
|
@@ -14695,71 +14808,73 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14695
14808
|
*/
|
|
14696
14809
|
_getItemsToHydrate() {
|
|
14697
14810
|
const items = [];
|
|
14698
|
-
const
|
|
14699
|
-
|
|
14700
|
-
|
|
14701
|
-
|
|
14702
|
-
for (let i = 0; i < Math.min(ITEMS_PER_QUEUE, this.newQ.length); i++) {
|
|
14703
|
-
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));
|
|
14704
14815
|
}
|
|
14705
|
-
for (let i = 0; i < Math.min(
|
|
14816
|
+
for (let i = 0; i < Math.min(FAILED_PREFETCH, this.failedQ.length); i++) {
|
|
14706
14817
|
items.push(this.failedQ.peek(i));
|
|
14707
14818
|
}
|
|
14708
14819
|
return items;
|
|
14709
14820
|
}
|
|
14710
14821
|
/**
|
|
14711
14822
|
* Selects the next item to present to the user.
|
|
14712
|
-
*
|
|
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.
|
|
14713
14829
|
*/
|
|
14714
14830
|
_selectNextItemToHydrate() {
|
|
14715
|
-
|
|
14716
|
-
let newBound = 0.1;
|
|
14717
|
-
let reviewBound = 0.75;
|
|
14718
|
-
if (this.reviewQ.length === 0 && this.failedQ.length === 0 && this.newQ.length === 0) {
|
|
14831
|
+
if (this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
14719
14832
|
return null;
|
|
14720
14833
|
}
|
|
14721
14834
|
if (this._secondsRemaining < 2 && this.failedQ.length === 0 && this._minCardsGuarantee <= 0) {
|
|
14722
14835
|
return null;
|
|
14723
14836
|
}
|
|
14724
14837
|
if (this._secondsRemaining <= 0 && this._minCardsGuarantee <= 0) {
|
|
14725
|
-
|
|
14726
|
-
return this.failedQ.peek(0);
|
|
14727
|
-
} else {
|
|
14728
|
-
return null;
|
|
14729
|
-
}
|
|
14730
|
-
}
|
|
14731
|
-
if (this.newQ.dequeueCount < this.sources.length && this.newQ.length) {
|
|
14732
|
-
return this.newQ.peek(0);
|
|
14838
|
+
return this.failedQ.length > 0 ? this.failedQ.peek(0) : null;
|
|
14733
14839
|
}
|
|
14734
|
-
const
|
|
14735
|
-
|
|
14736
|
-
|
|
14737
|
-
if (availableTime > 20) {
|
|
14738
|
-
newBound = 0.5;
|
|
14739
|
-
reviewBound = 0.9;
|
|
14740
|
-
} else if (this._secondsRemaining - cleanupTime > 20) {
|
|
14741
|
-
newBound = 0.05;
|
|
14742
|
-
reviewBound = 0.9;
|
|
14743
|
-
} else {
|
|
14744
|
-
newBound = 0.01;
|
|
14745
|
-
reviewBound = 0.1;
|
|
14840
|
+
const supplyTop = this.supplyQ.length > 0 ? this.supplyQ.peek(0) : null;
|
|
14841
|
+
if (this._minCardsGuarantee > 0 && supplyTop) {
|
|
14842
|
+
return supplyTop;
|
|
14746
14843
|
}
|
|
14747
|
-
if (this.failedQ.length
|
|
14748
|
-
|
|
14844
|
+
if (this.failedQ.length > 0 && this._shouldInterleaveFailed(supplyTop !== null)) {
|
|
14845
|
+
return this.failedQ.peek(0);
|
|
14749
14846
|
}
|
|
14750
|
-
if (
|
|
14751
|
-
|
|
14847
|
+
if (supplyTop) {
|
|
14848
|
+
return supplyTop;
|
|
14752
14849
|
}
|
|
14753
|
-
if (
|
|
14754
|
-
return this.newQ.peek(0);
|
|
14755
|
-
} else if (choice < reviewBound && this.reviewQ.length) {
|
|
14756
|
-
return this.reviewQ.peek(0);
|
|
14757
|
-
} else if (this.failedQ.length) {
|
|
14850
|
+
if (this.failedQ.length > 0) {
|
|
14758
14851
|
return this.failedQ.peek(0);
|
|
14759
|
-
} else {
|
|
14760
|
-
this.log(`No more cards available for the session!`);
|
|
14761
|
-
return null;
|
|
14762
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;
|
|
14763
14878
|
}
|
|
14764
14879
|
async nextCard(action = "dismiss-success") {
|
|
14765
14880
|
this.dismissCurrentCard(action);
|
|
@@ -14767,22 +14882,21 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14767
14882
|
this._minCardsGuarantee--;
|
|
14768
14883
|
this.log(`[CardGuarantee] ${this._minCardsGuarantee} guaranteed cards remaining`);
|
|
14769
14884
|
}
|
|
14770
|
-
if (this._replanPromise && this.
|
|
14885
|
+
if (this._replanPromise && this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
14771
14886
|
this.log("nextCard: queues empty, awaiting in-flight replan before drawing");
|
|
14772
14887
|
await this._replanPromise;
|
|
14773
14888
|
}
|
|
14774
|
-
if (this.
|
|
14889
|
+
if (this.supplyQ.length <= _SessionController.DEPLETION_PREFETCH_THRESHOLD && this._secondsRemaining > 0 && !this._replanPromise) {
|
|
14775
14890
|
this._suppressQualityReplan = false;
|
|
14776
|
-
const otherContent = this.reviewQ.length + this.failedQ.length;
|
|
14777
14891
|
this.log(
|
|
14778
|
-
`[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.`
|
|
14779
14893
|
);
|
|
14780
14894
|
void this.requestReplan({ label: "auto:depletion", mode: "merge" });
|
|
14781
14895
|
}
|
|
14782
14896
|
const REPLAN_BUFFER = 3;
|
|
14783
|
-
if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.
|
|
14897
|
+
if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.supplyQ.length > 0 && !this._replanPromise) {
|
|
14784
14898
|
this.log(
|
|
14785
|
-
`[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining (
|
|
14899
|
+
`[AutoReplan:quality] ${this._wellIndicatedRemaining} well-indicated cards remaining (supplyQ: ${this.supplyQ.length}). Triggering background replan.`
|
|
14786
14900
|
);
|
|
14787
14901
|
void this.requestReplan({ label: "auto:quality" });
|
|
14788
14902
|
}
|
|
@@ -14794,12 +14908,12 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14794
14908
|
const WEDGE_MAX_EMPTY_STREAK = 3;
|
|
14795
14909
|
const WEDGE_BACKOFF_MS = 250;
|
|
14796
14910
|
let wedgeEmptyStreak = 0;
|
|
14797
|
-
while (this._secondsRemaining > 0 && this.
|
|
14911
|
+
while (this._secondsRemaining > 0 && this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
14798
14912
|
this.log(
|
|
14799
14913
|
`[WedgeBreaker] All queues empty with ${this._secondsRemaining}s remaining. Running pipeline (attempt ${wedgeEmptyStreak + 1}/${WEDGE_MAX_EMPTY_STREAK}).`
|
|
14800
14914
|
);
|
|
14801
14915
|
await this._replanUncoalesced({ label: "wedge-breaker" });
|
|
14802
|
-
if (this.
|
|
14916
|
+
if (this.supplyQ.length === 0 && this.failedQ.length === 0) {
|
|
14803
14917
|
wedgeEmptyStreak++;
|
|
14804
14918
|
if (wedgeEmptyStreak >= WEDGE_MAX_EMPTY_STREAK) {
|
|
14805
14919
|
this.log(
|
|
@@ -14829,15 +14943,16 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14829
14943
|
await this.hydrationService.ensureHydratedCards();
|
|
14830
14944
|
this._currentCard = card;
|
|
14831
14945
|
const origin = nextItem.status === "review" || nextItem.status === "failed-review" ? "review" : nextItem.status === "new" || nextItem.status === "failed-new" ? "new" : "failed";
|
|
14832
|
-
const queueSource = nextItem.status.startsWith("failed") ? "failedQ" :
|
|
14946
|
+
const queueSource = nextItem.status.startsWith("failed") ? "failedQ" : "supplyQ";
|
|
14833
14947
|
recordCardPresentation(
|
|
14834
14948
|
nextItem.cardID,
|
|
14835
14949
|
nextItem.courseID,
|
|
14836
14950
|
this.courseNameCache.get(nextItem.courseID),
|
|
14837
14951
|
origin,
|
|
14838
|
-
queueSource
|
|
14952
|
+
queueSource,
|
|
14953
|
+
nextItem.score
|
|
14839
14954
|
);
|
|
14840
|
-
snapshotQueues(this.
|
|
14955
|
+
snapshotQueues(this.supplyQ.length, this.failedQ.length);
|
|
14841
14956
|
return card;
|
|
14842
14957
|
}
|
|
14843
14958
|
this.log(`Skipping card ${nextItem.cardID}: hydration failed, trying next`);
|
|
@@ -14907,6 +15022,7 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14907
15022
|
};
|
|
14908
15023
|
}
|
|
14909
15024
|
this.failedQ.add(failedItem, failedItem.cardID);
|
|
15025
|
+
this._supplyDrawsSinceFailed = 0;
|
|
14910
15026
|
} else if (action === "dismiss-error") {
|
|
14911
15027
|
this.hydrationService.removeCard(this._currentCard.item.cardID);
|
|
14912
15028
|
} else if (action === "dismiss-failed") {
|
|
@@ -14920,15 +15036,15 @@ var SessionController = class _SessionController extends Loggable {
|
|
|
14920
15036
|
removeItemFromQueue(item) {
|
|
14921
15037
|
this._clearDurableRequirement(item.cardID);
|
|
14922
15038
|
this._servedCardIds.add(item.cardID);
|
|
14923
|
-
if (this.
|
|
14924
|
-
this.
|
|
14925
|
-
|
|
14926
|
-
|
|
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++;
|
|
14927
15045
|
if (this._wellIndicatedRemaining > 0) {
|
|
14928
15046
|
this._wellIndicatedRemaining--;
|
|
14929
15047
|
}
|
|
14930
|
-
} else if (this.failedQ.peek(0)?.cardID === item.cardID) {
|
|
14931
|
-
this.failedQ.dequeue((queueItem) => queueItem.cardID);
|
|
14932
15048
|
}
|
|
14933
15049
|
}
|
|
14934
15050
|
/**
|
|
@@ -15027,6 +15143,7 @@ export {
|
|
|
15027
15143
|
areQuestionRecords,
|
|
15028
15144
|
buildStrategyStateId,
|
|
15029
15145
|
captureMixerRun,
|
|
15146
|
+
clearSrsBacklogDebug,
|
|
15030
15147
|
computeDeviation,
|
|
15031
15148
|
computeEffectiveWeight,
|
|
15032
15149
|
computeOutcomeSignal,
|
|
@@ -15047,6 +15164,7 @@ export {
|
|
|
15047
15164
|
getRegisteredNavigator,
|
|
15048
15165
|
getRegisteredNavigatorNames,
|
|
15049
15166
|
getRegisteredNavigatorRole,
|
|
15167
|
+
getSrsBacklogDebug,
|
|
15050
15168
|
getStudySource,
|
|
15051
15169
|
hasRegisteredNavigator,
|
|
15052
15170
|
importParsedCards,
|