@vue-skuilder/db 0.2.4 → 0.2.7

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/index.js CHANGED
@@ -872,6 +872,95 @@ var init_courseLookupDB = __esm({
872
872
  }
873
873
  });
874
874
 
875
+ // src/core/navigators/diversityRerank.ts
876
+ var diversityRerank_exports = {};
877
+ __export(diversityRerank_exports, {
878
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
879
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
880
+ diversityRerank: () => diversityRerank
881
+ });
882
+ function diversityRerank(cards, opts = {}) {
883
+ const strength = opts.strength ?? DIVERSITY_STRENGTH;
884
+ const floor = opts.floor ?? DIVERSITY_FLOOR;
885
+ const n = cards.length;
886
+ if (n <= 1) return cards;
887
+ const df = /* @__PURE__ */ new Map();
888
+ for (const card of cards) {
889
+ for (const tag of card.tags ?? []) {
890
+ df.set(tag, (df.get(tag) ?? 0) + 1);
891
+ }
892
+ }
893
+ const idf = /* @__PURE__ */ new Map();
894
+ for (const [tag, freq] of df) {
895
+ idf.set(tag, Math.log(n / freq));
896
+ }
897
+ const remaining = [...cards];
898
+ const emittedCount = /* @__PURE__ */ new Map();
899
+ const out = [];
900
+ const repetitionLoad = (card) => {
901
+ let load = 0;
902
+ for (const tag of card.tags ?? []) {
903
+ const seen = emittedCount.get(tag);
904
+ if (seen) load += (idf.get(tag) ?? 0) * seen;
905
+ }
906
+ return load;
907
+ };
908
+ while (remaining.length > 0) {
909
+ let bestIdx = 0;
910
+ let bestValue = -Infinity;
911
+ let bestPenalty = 1;
912
+ let bestLoad = 0;
913
+ for (let i = 0; i < remaining.length; i++) {
914
+ const card = remaining[i];
915
+ const load = repetitionLoad(card);
916
+ const penalty = load > 0 ? Math.max(floor, 1 / (1 + strength * load)) : 1;
917
+ const value = card.score * penalty;
918
+ if (value > bestValue) {
919
+ bestValue = value;
920
+ bestIdx = i;
921
+ bestPenalty = penalty;
922
+ bestLoad = load;
923
+ }
924
+ }
925
+ const [picked] = remaining.splice(bestIdx, 1);
926
+ if (Number.isFinite(picked.score) && bestPenalty < 1) {
927
+ const newScore = picked.score * bestPenalty;
928
+ out.push({
929
+ ...picked,
930
+ score: newScore,
931
+ provenance: [
932
+ ...picked.provenance,
933
+ {
934
+ strategy: STRATEGY,
935
+ strategyId: STRATEGY_ID,
936
+ strategyName: STRATEGY_NAME,
937
+ action: "penalized",
938
+ score: newScore,
939
+ reason: `repeated tags (load ${bestLoad.toFixed(2)}) \u2192 \xD7${bestPenalty.toFixed(2)}`
940
+ }
941
+ ]
942
+ });
943
+ } else {
944
+ out.push(picked);
945
+ }
946
+ for (const tag of picked.tags ?? []) {
947
+ emittedCount.set(tag, (emittedCount.get(tag) ?? 0) + 1);
948
+ }
949
+ }
950
+ return out;
951
+ }
952
+ var DIVERSITY_STRENGTH, DIVERSITY_FLOOR, STRATEGY, STRATEGY_ID, STRATEGY_NAME;
953
+ var init_diversityRerank = __esm({
954
+ "src/core/navigators/diversityRerank.ts"() {
955
+ "use strict";
956
+ DIVERSITY_STRENGTH = 0.6;
957
+ DIVERSITY_FLOOR = 0.3;
958
+ STRATEGY = "diversityRerank";
959
+ STRATEGY_ID = "DIVERSITY_RERANK";
960
+ STRATEGY_NAME = "Diversity Re-rank";
961
+ }
962
+ });
963
+
875
964
  // src/core/navigators/PipelineDebugger.ts
876
965
  var PipelineDebugger_exports = {};
877
966
  __export(PipelineDebugger_exports, {
@@ -1939,13 +2028,14 @@ var elo_exports = {};
1939
2028
  __export(elo_exports, {
1940
2029
  default: () => ELONavigator
1941
2030
  });
1942
- var import_common5, ELONavigator;
2031
+ var import_common5, ELO_RELEVANCE_SIGMA, ELONavigator;
1943
2032
  var init_elo = __esm({
1944
2033
  "src/core/navigators/generators/elo.ts"() {
1945
2034
  "use strict";
1946
2035
  init_navigators();
1947
2036
  import_common5 = require("@vue-skuilder/common");
1948
2037
  init_logger();
2038
+ ELO_RELEVANCE_SIGMA = 300;
1949
2039
  ELONavigator = class extends ContentNavigator {
1950
2040
  /** Human-readable name for CardGenerator interface */
1951
2041
  name;
@@ -1985,8 +2075,8 @@ var init_elo = __esm({
1985
2075
  const scored = newCards.map((c) => {
1986
2076
  const cardElo = c.elo ?? 1e3;
1987
2077
  const distance = Math.abs(cardElo - userGlobalElo);
1988
- const rawScore = Math.max(0, 1 - distance / 500);
1989
- const samplingKey = rawScore > 0 ? Math.random() ** (1 / rawScore) : 0;
2078
+ const relevance = Math.exp(-((distance / ELO_RELEVANCE_SIGMA) ** 2));
2079
+ const samplingKey = relevance * (0.5 + 0.5 * Math.random());
1990
2080
  return {
1991
2081
  cardId: c.cardID,
1992
2082
  courseId: c.courseID,
@@ -1998,7 +2088,7 @@ var init_elo = __esm({
1998
2088
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
1999
2089
  action: "generated",
2000
2090
  score: samplingKey,
2001
- reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), raw ${rawScore.toFixed(3)}, key ${samplingKey.toFixed(3)}`
2091
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), relevance ${relevance.toFixed(3)}, key ${samplingKey.toFixed(3)}`
2002
2092
  }
2003
2093
  ]
2004
2094
  };
@@ -2066,7 +2156,7 @@ function shuffleInPlace(arr) {
2066
2156
  function pickTopByScore(cards, limit) {
2067
2157
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
2068
2158
  }
2069
- var DEFAULT_FRESHNESS_WINDOW, DEFAULT_MAX_DIRECT_PER_RUN, DEFAULT_MAX_SUPPORT_PER_RUN, DEFAULT_HIERARCHY_DEPTH, DEFAULT_MIN_COUNT, BASE_TARGET_SCORE, BASE_SUPPORT_SCORE, DISCOVERED_SUPPORT_SCORE, MAX_TARGET_MULTIPLIER, MAX_SUPPORT_MULTIPLIER, PRESCRIBED_DEBUG_VERSION, PrescribedCardsGenerator;
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;
2070
2160
  var init_prescribed = __esm({
2071
2161
  "src/core/navigators/generators/prescribed.ts"() {
2072
2162
  "use strict";
@@ -2077,9 +2167,12 @@ var init_prescribed = __esm({
2077
2167
  DEFAULT_MAX_SUPPORT_PER_RUN = 3;
2078
2168
  DEFAULT_HIERARCHY_DEPTH = 2;
2079
2169
  DEFAULT_MIN_COUNT = 3;
2170
+ DEFAULT_PRACTICE_MIN_COUNT = 3;
2171
+ DEFAULT_MAX_PRACTICE_PER_RUN = 4;
2080
2172
  BASE_TARGET_SCORE = 1;
2081
2173
  BASE_SUPPORT_SCORE = 0.8;
2082
2174
  DISCOVERED_SUPPORT_SCORE = 12;
2175
+ BASE_PRACTICE_SCORE = 1;
2083
2176
  MAX_TARGET_MULTIPLIER = 8;
2084
2177
  MAX_SUPPORT_MULTIPLIER = 4;
2085
2178
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -2187,7 +2280,18 @@ var init_prescribed = __esm({
2187
2280
  courseId,
2188
2281
  emittedIds
2189
2282
  );
2190
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
2283
+ const practiceCards = this.buildPracticeCards({
2284
+ group,
2285
+ courseId,
2286
+ emittedIds,
2287
+ cardsByTag,
2288
+ hierarchyConfigs,
2289
+ userTagElo,
2290
+ userGlobalElo,
2291
+ activeIds,
2292
+ seenIds
2293
+ });
2294
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
2191
2295
  }
2192
2296
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
2193
2297
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
@@ -2215,6 +2319,7 @@ var init_prescribed = __esm({
2215
2319
  const surfacedByGroup = /* @__PURE__ */ new Map();
2216
2320
  for (const card of finalCards) {
2217
2321
  const prov = card.provenance[0];
2322
+ if (prov?.reason.includes("mode=practice")) continue;
2218
2323
  const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
2219
2324
  const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
2220
2325
  if (!groupId) continue;
@@ -2284,7 +2389,12 @@ var init_prescribed = __esm({
2284
2389
  enabled: raw.hierarchyWalk?.enabled !== false,
2285
2390
  maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
2286
2391
  },
2287
- retireOnEncounter: raw.retireOnEncounter !== false
2392
+ retireOnEncounter: raw.retireOnEncounter !== false,
2393
+ practiceTagPatterns: dedupe(
2394
+ Array.isArray(raw.practiceTagPatterns) ? raw.practiceTagPatterns.filter((v) => typeof v === "string") : []
2395
+ ),
2396
+ practiceMinCount: typeof raw.practiceMinCount === "number" ? raw.practiceMinCount : DEFAULT_PRACTICE_MIN_COUNT,
2397
+ maxPracticeCardsPerRun: typeof raw.maxPracticeCardsPerRun === "number" ? raw.maxPracticeCardsPerRun : DEFAULT_MAX_PRACTICE_PER_RUN
2288
2398
  })).filter((g) => g.targetCardIds.length > 0);
2289
2399
  return { groups };
2290
2400
  } catch {
@@ -2507,6 +2617,92 @@ var init_prescribed = __esm({
2507
2617
  }
2508
2618
  return cards;
2509
2619
  }
2620
+ /**
2621
+ * Emit drill cards for *unlocked-but-under-practiced* skills.
2622
+ *
2623
+ * For each course tag matching the group's `practiceTagPatterns` that is both
2624
+ * unlocked (all hierarchy prerequisites met — i.e. the learner has been
2625
+ * introduced to it) and under-practiced (per-tag attempt count below
2626
+ * `practiceMinCount`), this resolves cards carrying that tag and emits them
2627
+ * into the candidate pool. It exists because global-ELO retrieval
2628
+ * systematically fails to fetch the (low-ELO) drill cards for a
2629
+ * freshly-introduced skill — putting them in the pool here lets the pipeline's
2630
+ * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2631
+ * this method's job; it only guarantees presence.
2632
+ *
2633
+ * Fully data-driven: the unlock relation comes from the hierarchy config and
2634
+ * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
2635
+ */
2636
+ buildPracticeCards(args) {
2637
+ const {
2638
+ group,
2639
+ courseId,
2640
+ emittedIds,
2641
+ cardsByTag,
2642
+ hierarchyConfigs,
2643
+ userTagElo,
2644
+ userGlobalElo,
2645
+ activeIds,
2646
+ seenIds
2647
+ } = args;
2648
+ const patterns = group.practiceTagPatterns ?? [];
2649
+ if (patterns.length === 0) return [];
2650
+ const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2651
+ const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2652
+ const practiceTags = [...cardsByTag.keys()].filter(
2653
+ (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2654
+ );
2655
+ if (practiceTags.length === 0) return [];
2656
+ const practiceCardIds = this.findDiscoveredSupportCards({
2657
+ supportTags: practiceTags,
2658
+ cardsByTag,
2659
+ activeIds,
2660
+ seenIds,
2661
+ excludedIds: emittedIds,
2662
+ limit: maxPractice
2663
+ });
2664
+ if (practiceCardIds.length === 0) return [];
2665
+ logger.info(
2666
+ `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2667
+ );
2668
+ const cards = [];
2669
+ for (const cardId of practiceCardIds) {
2670
+ emittedIds.add(cardId);
2671
+ cards.push({
2672
+ cardId,
2673
+ courseId,
2674
+ score: BASE_PRACTICE_SCORE,
2675
+ provenance: [
2676
+ {
2677
+ strategy: "prescribed",
2678
+ strategyName: this.strategyName || this.name,
2679
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2680
+ action: "generated",
2681
+ score: BASE_PRACTICE_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}`
2683
+ }
2684
+ ]
2685
+ });
2686
+ }
2687
+ return cards;
2688
+ }
2689
+ /**
2690
+ * True for a skill that was *gated and is now reached*: it has at least one
2691
+ * declared hierarchy prerequisite set, and every set is fully satisfied by the
2692
+ * learner's per-tag ELO. This deliberately EXCLUDES tags with no prerequisites
2693
+ * — an ungated tag was never "introduced" in the curricular sense, so it isn't
2694
+ * a post-intro drill target (e.g. whole-word spelling tags that share the
2695
+ * `gpc:exercise:*` prefix but have no intro gate). Those are left to normal
2696
+ * ELO retrieval. This is the precise population the retrieval gap strands:
2697
+ * just-unlocked, low-ELO skills.
2698
+ */
2699
+ isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) {
2700
+ const prereqSets = hierarchyConfigs.map((hierarchy) => hierarchy.prerequisites[tag]).filter((prereqs) => Array.isArray(prereqs) && prereqs.length > 0);
2701
+ if (prereqSets.length === 0) return false;
2702
+ return prereqSets.every(
2703
+ (prereqs) => prereqs.every((pr) => this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo))
2704
+ );
2705
+ }
2510
2706
  findSupportCardsByTags(group, tagsByCard, supportTags) {
2511
2707
  if (supportTags.length === 0) {
2512
2708
  return [];
@@ -4300,7 +4496,7 @@ function logResultCards(cards) {
4300
4496
  for (let i = 0; i < cards.length; i++) {
4301
4497
  const c = cards[i];
4302
4498
  const tags = c.tags?.slice(0, 3).join(", ") || "";
4303
- const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
4499
+ const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint" || p.strategy === "diversityRerank").map((p) => {
4304
4500
  const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
4305
4501
  return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
4306
4502
  }).join(" | ");
@@ -4332,6 +4528,7 @@ var init_Pipeline = __esm({
4332
4528
  init_logger();
4333
4529
  init_orchestration();
4334
4530
  init_PipelineDebugger();
4531
+ init_diversityRerank();
4335
4532
  VERBOSE_RESULTS = true;
4336
4533
  Pipeline = class extends ContentNavigator {
4337
4534
  generator;
@@ -4505,6 +4702,7 @@ var init_Pipeline = __esm({
4505
4702
  this._ephemeralHints = null;
4506
4703
  cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
4507
4704
  }
4705
+ cards = diversityRerank(cards);
4508
4706
  cards.sort((a, b) => b.score - a.score);
4509
4707
  const tFilter = performance.now();
4510
4708
  const result = cards.slice(0, limit);
@@ -5073,6 +5271,7 @@ var init_3 = __esm({
5073
5271
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
5074
5272
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
5075
5273
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
5274
+ "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
5076
5275
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
5077
5276
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
5078
5277
  "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
@@ -5098,9 +5297,12 @@ var init_3 = __esm({
5098
5297
  var navigators_exports = {};
5099
5298
  __export(navigators_exports, {
5100
5299
  ContentNavigator: () => ContentNavigator,
5300
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
5301
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
5101
5302
  NavigatorRole: () => NavigatorRole,
5102
5303
  NavigatorRoles: () => NavigatorRoles,
5103
5304
  Navigators: () => Navigators,
5305
+ diversityRerank: () => diversityRerank,
5104
5306
  getCardOrigin: () => getCardOrigin,
5105
5307
  getRegisteredNavigator: () => getRegisteredNavigator,
5106
5308
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
@@ -5184,6 +5386,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
5184
5386
  var init_navigators = __esm({
5185
5387
  "src/core/navigators/index.ts"() {
5186
5388
  "use strict";
5389
+ init_diversityRerank();
5187
5390
  init_PipelineDebugger();
5188
5391
  init_logger();
5189
5392
  init_();
@@ -10186,6 +10389,8 @@ __export(index_exports, {
10186
10389
  ContentNavigator: () => ContentNavigator,
10187
10390
  CouchDBToStaticPacker: () => CouchDBToStaticPacker,
10188
10391
  CourseLookup: () => CourseLookup,
10392
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
10393
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
10189
10394
  DocType: () => DocType,
10190
10395
  DocTypePrefixes: () => DocTypePrefixes,
10191
10396
  ENV: () => ENV,
@@ -10211,6 +10416,7 @@ __export(index_exports, {
10211
10416
  computeSpread: () => computeSpread,
10212
10417
  computeStrategyGradient: () => computeStrategyGradient,
10213
10418
  createOrchestrationContext: () => createOrchestrationContext,
10419
+ diversityRerank: () => diversityRerank,
10214
10420
  docIsDeleted: () => docIsDeleted,
10215
10421
  endSessionTracking: () => endSessionTracking,
10216
10422
  ensureAppDataDirectory: () => ensureAppDataDirectory,
@@ -11322,8 +11528,17 @@ var ItemQueue = class {
11322
11528
  * Merge new items into the front of the queue, skipping duplicates.
11323
11529
  * Used by additive replans to inject high-quality candidates without
11324
11530
  * discarding the existing queue contents.
11531
+ *
11532
+ * `forceFrontIds` carries the mandatory (`+INF`) cards in this batch — a
11533
+ * durable `requireCard`/`requireTag` re-asserted by every replan. An ordinary
11534
+ * duplicate is left in place (skip), but a mandatory one that's *already*
11535
+ * queued is pulled out of its current slot so it rejoins at the front in batch
11536
+ * order. Without this, an additive merge unshifts fresh non-required cards
11537
+ * ahead of an already-present required card, steadily burying it until it never
11538
+ * gets drawn — defeating the "must appear" guarantee. Returns the count of
11539
+ * genuinely new cards added (re-fronted duplicates are not counted).
11325
11540
  */
11326
- mergeToFront(items, cardIdExtractor) {
11541
+ mergeToFront(items, cardIdExtractor, forceFrontIds) {
11327
11542
  let added = 0;
11328
11543
  const toInsert = [];
11329
11544
  for (const item of items) {
@@ -11332,6 +11547,11 @@ var ItemQueue = class {
11332
11547
  this.seenCardIds.push(cardId);
11333
11548
  toInsert.push(item);
11334
11549
  added++;
11550
+ } else if (forceFrontIds?.has(cardId)) {
11551
+ const idx = this.q.findIndex((qi) => cardIdExtractor(qi) === cardId);
11552
+ if (idx >= 0) {
11553
+ toInsert.push(...this.q.splice(idx, 1));
11554
+ }
11335
11555
  }
11336
11556
  }
11337
11557
  this.q.unshift(...toInsert);
@@ -13173,7 +13393,15 @@ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834"
13173
13393
  var spinnerFrame = 0;
13174
13394
  var overlayEl = null;
13175
13395
  var pollHandle = null;
13176
- var expanded = { reviewQ: false, newQ: false, failedQ: false };
13396
+ var lastSnapshot = null;
13397
+ var copyFlashUntil = 0;
13398
+ var minified = false;
13399
+ var expanded = {
13400
+ reviewQ: false,
13401
+ newQ: false,
13402
+ failedQ: false,
13403
+ drawn: false
13404
+ };
13177
13405
  function toggleSessionOverlay() {
13178
13406
  if (typeof document === "undefined") {
13179
13407
  logger.info("[Session Overlay] No DOM available (non-browser host); overlay unavailable.");
@@ -13188,6 +13416,7 @@ function toggleSessionOverlay() {
13188
13416
  }
13189
13417
  }
13190
13418
  function mount() {
13419
+ minified = false;
13191
13420
  overlayEl = document.createElement("div");
13192
13421
  overlayEl.id = OVERLAY_ID;
13193
13422
  Object.assign(overlayEl.style, {
@@ -13226,11 +13455,23 @@ function render() {
13226
13455
  spinnerFrame++;
13227
13456
  const ctrl = getActiveController();
13228
13457
  if (!ctrl) {
13229
- overlayEl.innerHTML = headerHtml() + `<div style="opacity:.65">No active session.</div>`;
13458
+ lastSnapshot = null;
13459
+ overlayEl.innerHTML = headerHtml() + (minified ? "" : `<div style="opacity:.65">No active session.</div>`);
13460
+ attachHandlers();
13230
13461
  return;
13231
13462
  }
13232
13463
  const s = ctrl.getDebugSnapshot();
13233
- overlayEl.innerHTML = headerHtml() + replanHtml(s) + metaHtml(s) + hintsHtml(s.sessionHints) + queueHtml("reviewQ", "reviewQ", s.reviewQ) + queueHtml("newQ", "newQ", s.newQ) + queueHtml("failedQ", "failedQ", s.failedQ);
13464
+ lastSnapshot = s;
13465
+ if (minified) {
13466
+ overlayEl.innerHTML = headerHtml();
13467
+ attachHandlers();
13468
+ return;
13469
+ }
13470
+ overlayEl.innerHTML = headerHtml() + replanHtml(s) + metaHtml(s) + hintsHtml(s.sessionHints) + queueHtml("reviewQ", "reviewQ", s.reviewQ) + queueHtml("newQ", "newQ", s.newQ) + queueHtml("failedQ", "failedQ", s.failedQ) + drawnHtml("drawn", s.drawnCards);
13471
+ attachHandlers();
13472
+ }
13473
+ function attachHandlers() {
13474
+ if (!overlayEl) return;
13234
13475
  overlayEl.querySelectorAll("[data-q]").forEach((el) => {
13235
13476
  el.onclick = () => {
13236
13477
  const key = el.dataset.q;
@@ -13239,9 +13480,45 @@ function render() {
13239
13480
  render();
13240
13481
  };
13241
13482
  });
13483
+ const copyBtn = overlayEl.querySelector("[data-copy]");
13484
+ if (copyBtn) {
13485
+ copyBtn.onclick = (ev) => {
13486
+ ev.stopPropagation();
13487
+ copySnapshot();
13488
+ };
13489
+ }
13490
+ const minBtn = overlayEl.querySelector("[data-min]");
13491
+ if (minBtn) {
13492
+ minBtn.onclick = (ev) => {
13493
+ ev.stopPropagation();
13494
+ minified = !minified;
13495
+ render();
13496
+ };
13497
+ }
13498
+ }
13499
+ function copySnapshot() {
13500
+ const text = snapshotToText(lastSnapshot);
13501
+ const flash = () => {
13502
+ copyFlashUntil = Date.now() + 1200;
13503
+ render();
13504
+ };
13505
+ const clip = typeof navigator !== "undefined" ? navigator.clipboard : void 0;
13506
+ if (clip?.writeText) {
13507
+ clip.writeText(text).then(flash, (err) => {
13508
+ logger.warn(`[Session Overlay] Clipboard write failed: ${String(err)}`);
13509
+ });
13510
+ } else {
13511
+ logger.info(`[Session Overlay] Clipboard unavailable; snapshot follows:
13512
+ ${text}`);
13513
+ }
13242
13514
  }
13243
13515
  function headerHtml() {
13244
- return `<div style="font-weight:600;color:#93c5fd;margin-bottom:4px">\u2699 SessionController</div>`;
13516
+ const flashing = Date.now() < copyFlashUntil;
13517
+ const btnLabel = flashing ? "\u2713 copied" : "\u2398 copy";
13518
+ const btnColor = flashing ? "#86efac" : "#93c5fd";
13519
+ const copyBtn = `<span data-copy style="cursor:pointer;float:right;font-weight:400;color:${btnColor};border:1px solid currentColor;border-radius:4px;padding:0 4px;line-height:1.3">${btnLabel}</span>`;
13520
+ const minBtn = `<span data-min title="${minified ? "Restore" : "Minify"}" style="cursor:pointer;float:right;font-weight:400;color:#93c5fd;border:1px solid currentColor;border-radius:4px;padding:0 5px;margin-right:4px;line-height:1.3">${minified ? "\u25A2" : "\u2014"}</span>`;
13521
+ return `<div style="font-weight:600;color:#93c5fd;margin-bottom:${minified ? 0 : 4}px">${copyBtn}${minBtn}\u2699 SessionController</div>`;
13245
13522
  }
13246
13523
  function replanHtml(s) {
13247
13524
  if (!s.replanActive) {
@@ -13284,22 +13561,98 @@ function hintsHtml(h) {
13284
13561
  }
13285
13562
  function queueHtml(key, label, q) {
13286
13563
  const collapsible = q.length > INLINE_THRESHOLD;
13287
- const isOpen = !collapsible || expanded[key];
13564
+ const isOpen = collapsible && expanded[key];
13288
13565
  const caret = collapsible ? expanded[key] ? "\u25BE " : "\u25B8 " : "";
13289
13566
  const drawn = q.dequeueCount ? ` <span style="opacity:.5">drawn ${q.dequeueCount}</span>` : "";
13290
13567
  const titleStyle = collapsible ? "cursor:pointer;color:#f9a8d4" : "color:#f9a8d4";
13291
13568
  const titleAttr = collapsible ? ` data-q="${key}"` : "";
13292
13569
  const title = `<div${titleAttr} style="${titleStyle}">${caret}${label}: ${q.length}${drawn}</div>`;
13293
- let body = "";
13294
- if (isOpen && q.cards.length) {
13295
- body = `<ol style="margin:2px 0 6px 0;padding-left:20px">` + q.cards.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join("") + `</ol>`;
13296
- } else if (!q.cards.length) {
13297
- body = `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
13298
- } else {
13299
- body = `<div style="margin:1px 0 6px 6px;opacity:.55">(${q.length} cards \u2014 click to expand)</div>`;
13570
+ if (!q.cards.length) {
13571
+ return title + `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
13572
+ }
13573
+ const shown = isOpen ? q.cards : q.cards.slice(0, INLINE_THRESHOLD);
13574
+ const hiddenCount = q.length - shown.length;
13575
+ 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">${esc(c)}</li>`).join("") + `</ol>`;
13577
+ if (collapsible) {
13578
+ const footer = isOpen ? "\u25BE show less" : `\u2026 +${hiddenCount} more`;
13579
+ body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
13300
13580
  }
13301
13581
  return title + body;
13302
13582
  }
13583
+ function outcomeGlyph(correct) {
13584
+ if (correct === true) return `<span style="color:#86efac">\u2713</span>`;
13585
+ if (correct === false) return `<span style="color:#fca5a5">\u2717</span>`;
13586
+ return `<span style="opacity:.5">\xB7</span>`;
13587
+ }
13588
+ function drawnHtml(key, drawn) {
13589
+ const collapsible = drawn.length > INLINE_THRESHOLD;
13590
+ const isOpen = collapsible && expanded[key];
13591
+ const caret = collapsible ? expanded[key] ? "\u25BE " : "\u25B8 " : "";
13592
+ const titleStyle = collapsible ? "cursor:pointer;color:#c4b5fd" : "color:#c4b5fd";
13593
+ const titleAttr = collapsible ? ` data-q="${key}"` : "";
13594
+ const title = `<div${titleAttr} style="${titleStyle}">${caret}drawn: ${drawn.length}</div>`;
13595
+ if (!drawn.length) {
13596
+ return title + `<div style="margin:1px 0 6px 6px;opacity:.5">none yet</div>`;
13597
+ }
13598
+ const shown = isOpen ? drawn : drawn.slice(0, INLINE_THRESHOLD);
13599
+ const hiddenCount = drawn.length - shown.length;
13600
+ const listMarginBottom = collapsible ? 2 : 6;
13601
+ const rows = shown.map((d) => {
13602
+ const retries = d.attempts > 1 ? `<span style="opacity:.5"> \xD7${d.attempts}</span>` : "";
13603
+ const time = `<span style="opacity:.45"> ${Math.round(d.timeSpentMs / 100) / 10}s</span>`;
13604
+ return `<li style="white-space:nowrap">${outcomeGlyph(d.correct)} ${esc(d.cardID)}<span style="opacity:.5"> [${esc(d.status)}]</span>${retries}${time}</li>`;
13605
+ }).join("");
13606
+ let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">${rows}</ol>`;
13607
+ if (collapsible) {
13608
+ const footer = isOpen ? "\u25BE show less" : `\u2026 +${hiddenCount} more`;
13609
+ body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
13610
+ }
13611
+ return title + body;
13612
+ }
13613
+ function snapshotToText(s) {
13614
+ if (!s) return "SessionController \u2014 no active session.";
13615
+ const lines = [];
13616
+ lines.push("=== SessionController ===");
13617
+ lines.push(`time ${formatTime(s.secondsRemaining)}`);
13618
+ if (s.hasCardGuarantee) lines.push(`guarantee: ${s.minCardsGuarantee}`);
13619
+ lines.push(`well-indicated left: ${s.wellIndicatedRemaining}`);
13620
+ lines.push(`current: ${s.currentCard ?? "\u2014"}`);
13621
+ lines.push(
13622
+ s.replanActive ? `replan: ACTIVE [${s.replanLabel ?? "(auto)"}]` : "replan: idle"
13623
+ );
13624
+ lines.push("");
13625
+ lines.push("sessionHints:");
13626
+ const h = s.sessionHints;
13627
+ const hintParts = [];
13628
+ if (h) {
13629
+ if (h.boostTags && Object.keys(h.boostTags).length)
13630
+ hintParts.push(` boost: ${Object.entries(h.boostTags).map(([k, v]) => `${k}\xD7${v}`).join(", ")}`);
13631
+ if (h.boostCards && Object.keys(h.boostCards).length)
13632
+ hintParts.push(` boostCards: ${Object.entries(h.boostCards).map(([k, v]) => `${k}\xD7${v}`).join(", ")}`);
13633
+ if (h.requireCards?.length) hintParts.push(` require: ${h.requireCards.join(", ")}`);
13634
+ if (h.requireTags?.length) hintParts.push(` requireTags: ${h.requireTags.join(", ")}`);
13635
+ if (h.excludeTags?.length) hintParts.push(` exclude: ${h.excludeTags.join(", ")}`);
13636
+ if (h.excludeCards?.length) hintParts.push(` excludeCards: ${h.excludeCards.join(", ")}`);
13637
+ }
13638
+ lines.push(hintParts.length ? hintParts.join("\n") : " none");
13639
+ const queueText = (label, q) => {
13640
+ lines.push("");
13641
+ lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
13642
+ q.cards.forEach((c, i) => lines.push(` ${i + 1}. ${c}`));
13643
+ };
13644
+ queueText("reviewQ", s.reviewQ);
13645
+ queueText("newQ", s.newQ);
13646
+ queueText("failedQ", s.failedQ);
13647
+ lines.push("");
13648
+ lines.push(`drawn: ${s.drawnCards.length}`);
13649
+ s.drawnCards.forEach((d, i) => {
13650
+ const mark = d.correct === true ? "\u2713" : d.correct === false ? "\u2717" : "\xB7";
13651
+ const time = `${Math.round(d.timeSpentMs / 100) / 10}s`;
13652
+ lines.push(` ${i + 1}. ${mark} ${d.cardID} [${d.status}] \xD7${d.attempts} ${time}`);
13653
+ });
13654
+ return lines.join("\n");
13655
+ }
13303
13656
  function formatTime(totalSeconds) {
13304
13657
  const s = Math.max(0, Math.round(totalSeconds));
13305
13658
  const m = Math.floor(s / 60);
@@ -13669,6 +14022,21 @@ var SessionController = class _SessionController extends Loggable {
13669
14022
  * recomputed per-run in `_runReplan` and would otherwise go stale.
13670
14023
  */
13671
14024
  _sessionHints = null;
14025
+ /**
14026
+ * Card IDs that have been *served* (drawn/consumed) this session. Populated
14027
+ * at the single consumption choke-point (removeItemFromQueue), so it reflects
14028
+ * a draw the instant it happens — earlier than `_sessionRecord`, which only
14029
+ * lands once the card is *responded to*.
14030
+ *
14031
+ * Used to keep already-served cards out of newQ on every (re)plan: a `new`
14032
+ * card shown once must never re-enter newQ this session. This is the general
14033
+ * guard against re-presentation — including the case where a replan in flight
14034
+ * captured a now-drawn card (e.g. a +INF require-injected follow-up the
14035
+ * depletion prefetch grabbed just before it was drawn). Reviews/failed cards
14036
+ * legitimately recur and are tracked by their own queues, so this only gates
14037
+ * `new`-origin candidates.
14038
+ */
14039
+ _servedCardIds = /* @__PURE__ */ new Set();
13672
14040
  /**
13673
14041
  * Consumer-supplied hooks invoked after each question response is processed.
13674
14042
  * Seeded from constructor options (threaded from
@@ -13878,6 +14246,7 @@ var SessionController = class _SessionController extends Loggable {
13878
14246
  if (opts.mode && opts.mode !== "replace") return true;
13879
14247
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
13880
14248
  if (opts.sessionHints !== void 0) return true;
14249
+ if (opts.mergeSessionHints !== void 0) return true;
13881
14250
  return false;
13882
14251
  }
13883
14252
  /**
@@ -13913,6 +14282,10 @@ var SessionController = class _SessionController extends Loggable {
13913
14282
  `[Replan] Session hints ${opts.sessionHints ? "set" : "cleared"}: ${JSON.stringify(opts.sessionHints)}`
13914
14283
  );
13915
14284
  }
14285
+ if (opts.mergeSessionHints !== void 0) {
14286
+ this._sessionHints = mergeHints2([this._sessionHints, opts.mergeSessionHints]) ?? null;
14287
+ this.log(`[Replan] Session hints merged: ${JSON.stringify(this._sessionHints)}`);
14288
+ }
13916
14289
  this._applyHintsToSources(opts.hints, opts.label);
13917
14290
  const labelTag = opts.label ? ` [${opts.label}]` : "";
13918
14291
  this.log(
@@ -13965,6 +14338,16 @@ var SessionController = class _SessionController extends Loggable {
13965
14338
  }
13966
14339
  return { length: q.length, dequeueCount: q.dequeueCount, cards };
13967
14340
  };
14341
+ const drawnCards = this._sessionRecord.map((r) => {
14342
+ const last = r.records[r.records.length - 1];
14343
+ return {
14344
+ cardID: r.item.cardID,
14345
+ status: r.item.status,
14346
+ attempts: r.records.length,
14347
+ correct: last && isQuestionRecord(last) ? last.isCorrect : null,
14348
+ timeSpentMs: r.records.reduce((sum, rec) => sum + rec.timeSpent, 0)
14349
+ };
14350
+ });
13968
14351
  return {
13969
14352
  secondsRemaining: this.secondsRemaining,
13970
14353
  hasCardGuarantee: this.hasCardGuarantee,
@@ -13976,7 +14359,8 @@ var SessionController = class _SessionController extends Loggable {
13976
14359
  replanLabel: this._activeReplanLabel,
13977
14360
  reviewQ: describe(this.reviewQ),
13978
14361
  newQ: describe(this.newQ),
13979
- failedQ: describe(this.failedQ)
14362
+ failedQ: describe(this.failedQ),
14363
+ drawnCards
13980
14364
  };
13981
14365
  }
13982
14366
  /**
@@ -14285,7 +14669,16 @@ var SessionController = class _SessionController extends Loggable {
14285
14669
  mixedWeighted
14286
14670
  );
14287
14671
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review").slice(0, this._initialReviewCap);
14288
- const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new").slice(0, newLimit);
14672
+ const newCandidates = mixedWeighted.filter(
14673
+ (w) => getCardOrigin(w) === "new" && !this._servedCardIds.has(w.cardId)
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 = [
14678
+ ...mandatoryWeighted,
14679
+ ...optionalWeighted.slice(0, Math.max(0, newLimit - mandatoryWeighted.length))
14680
+ ];
14681
+ const mandatoryIds = new Set(mandatoryWeighted.map((w) => w.cardId));
14289
14682
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
14290
14683
  let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
14291
14684
  if (!replan) {
@@ -14320,7 +14713,7 @@ var SessionController = class _SessionController extends Loggable {
14320
14713
  `;
14321
14714
  }
14322
14715
  if (additive) {
14323
- const added = this.newQ.mergeToFront(newItems, (item) => item.cardID);
14716
+ const added = this.newQ.mergeToFront(newItems, (item) => item.cardID, mandatoryIds);
14324
14717
  report += `Additive merge: ${added} new cards added to front of newQ
14325
14718
  `;
14326
14719
  } else if (replan) {
@@ -14422,7 +14815,7 @@ var SessionController = class _SessionController extends Loggable {
14422
14815
  this.log(
14423
14816
  `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) (${otherContent} in other queues) with ${this._secondsRemaining}s remaining. Triggering background replan.`
14424
14817
  );
14425
- void this.requestReplan({ label: "auto:depletion" });
14818
+ void this.requestReplan({ label: "auto:depletion", mode: "merge" });
14426
14819
  }
14427
14820
  const REPLAN_BUFFER = 3;
14428
14821
  if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
@@ -14563,6 +14956,8 @@ var SessionController = class _SessionController extends Loggable {
14563
14956
  * Remove an item from its source queue after consumption by nextCard().
14564
14957
  */
14565
14958
  removeItemFromQueue(item) {
14959
+ this._clearDurableRequirement(item.cardID);
14960
+ this._servedCardIds.add(item.cardID);
14566
14961
  if (this.reviewQ.peek(0)?.cardID === item.cardID) {
14567
14962
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
14568
14963
  } else if (this.newQ.peek(0)?.cardID === item.cardID) {
@@ -14574,6 +14969,27 @@ var SessionController = class _SessionController extends Loggable {
14574
14969
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
14575
14970
  }
14576
14971
  }
14972
+ /**
14973
+ * Remove a satisfied card ID from the durable session-hint `requireCards`
14974
+ * list. Called when a card is consumed (see removeItemFromQueue). No-op if
14975
+ * the card was not a durable requirement.
14976
+ *
14977
+ * Matches literal IDs only: a glob/pattern requirement (which may stand for
14978
+ * several cards) is NOT considered satisfied by a single draw and is left in
14979
+ * place — durable patterns are the caller's responsibility, one-shot `hints`
14980
+ * remain the right tool for them.
14981
+ */
14982
+ _clearDurableRequirement(cardID) {
14983
+ const req = this._sessionHints?.requireCards;
14984
+ if (!req || req.length === 0) return;
14985
+ const next = req.filter((id) => id !== cardID);
14986
+ if (next.length === req.length) return;
14987
+ this._sessionHints = {
14988
+ ...this._sessionHints,
14989
+ requireCards: next.length > 0 ? next : void 0
14990
+ };
14991
+ this.log(`[Replan] Durable requirement satisfied & cleared on draw: ${cardID}`);
14992
+ }
14577
14993
  /**
14578
14994
  * End the session and record learning outcomes.
14579
14995
  *
@@ -14629,6 +15045,8 @@ init_factory();
14629
15045
  ContentNavigator,
14630
15046
  CouchDBToStaticPacker,
14631
15047
  CourseLookup,
15048
+ DIVERSITY_FLOOR,
15049
+ DIVERSITY_STRENGTH,
14632
15050
  DocType,
14633
15051
  DocTypePrefixes,
14634
15052
  ENV,
@@ -14654,6 +15072,7 @@ init_factory();
14654
15072
  computeSpread,
14655
15073
  computeStrategyGradient,
14656
15074
  createOrchestrationContext,
15075
+ diversityRerank,
14657
15076
  docIsDeleted,
14658
15077
  endSessionTracking,
14659
15078
  ensureAppDataDirectory,