@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.mjs CHANGED
@@ -849,6 +849,95 @@ var init_courseLookupDB = __esm({
849
849
  }
850
850
  });
851
851
 
852
+ // src/core/navigators/diversityRerank.ts
853
+ var diversityRerank_exports = {};
854
+ __export(diversityRerank_exports, {
855
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
856
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
857
+ diversityRerank: () => diversityRerank
858
+ });
859
+ function diversityRerank(cards, opts = {}) {
860
+ const strength = opts.strength ?? DIVERSITY_STRENGTH;
861
+ const floor = opts.floor ?? DIVERSITY_FLOOR;
862
+ const n = cards.length;
863
+ if (n <= 1) return cards;
864
+ const df = /* @__PURE__ */ new Map();
865
+ for (const card of cards) {
866
+ for (const tag of card.tags ?? []) {
867
+ df.set(tag, (df.get(tag) ?? 0) + 1);
868
+ }
869
+ }
870
+ const idf = /* @__PURE__ */ new Map();
871
+ for (const [tag, freq] of df) {
872
+ idf.set(tag, Math.log(n / freq));
873
+ }
874
+ const remaining = [...cards];
875
+ const emittedCount = /* @__PURE__ */ new Map();
876
+ const out = [];
877
+ const repetitionLoad = (card) => {
878
+ let load = 0;
879
+ for (const tag of card.tags ?? []) {
880
+ const seen = emittedCount.get(tag);
881
+ if (seen) load += (idf.get(tag) ?? 0) * seen;
882
+ }
883
+ return load;
884
+ };
885
+ while (remaining.length > 0) {
886
+ let bestIdx = 0;
887
+ let bestValue = -Infinity;
888
+ let bestPenalty = 1;
889
+ let bestLoad = 0;
890
+ for (let i = 0; i < remaining.length; i++) {
891
+ const card = remaining[i];
892
+ const load = repetitionLoad(card);
893
+ const penalty = load > 0 ? Math.max(floor, 1 / (1 + strength * load)) : 1;
894
+ const value = card.score * penalty;
895
+ if (value > bestValue) {
896
+ bestValue = value;
897
+ bestIdx = i;
898
+ bestPenalty = penalty;
899
+ bestLoad = load;
900
+ }
901
+ }
902
+ const [picked] = remaining.splice(bestIdx, 1);
903
+ if (Number.isFinite(picked.score) && bestPenalty < 1) {
904
+ const newScore = picked.score * bestPenalty;
905
+ out.push({
906
+ ...picked,
907
+ score: newScore,
908
+ provenance: [
909
+ ...picked.provenance,
910
+ {
911
+ strategy: STRATEGY,
912
+ strategyId: STRATEGY_ID,
913
+ strategyName: STRATEGY_NAME,
914
+ action: "penalized",
915
+ score: newScore,
916
+ reason: `repeated tags (load ${bestLoad.toFixed(2)}) \u2192 \xD7${bestPenalty.toFixed(2)}`
917
+ }
918
+ ]
919
+ });
920
+ } else {
921
+ out.push(picked);
922
+ }
923
+ for (const tag of picked.tags ?? []) {
924
+ emittedCount.set(tag, (emittedCount.get(tag) ?? 0) + 1);
925
+ }
926
+ }
927
+ return out;
928
+ }
929
+ var DIVERSITY_STRENGTH, DIVERSITY_FLOOR, STRATEGY, STRATEGY_ID, STRATEGY_NAME;
930
+ var init_diversityRerank = __esm({
931
+ "src/core/navigators/diversityRerank.ts"() {
932
+ "use strict";
933
+ DIVERSITY_STRENGTH = 0.6;
934
+ DIVERSITY_FLOOR = 0.3;
935
+ STRATEGY = "diversityRerank";
936
+ STRATEGY_ID = "DIVERSITY_RERANK";
937
+ STRATEGY_NAME = "Diversity Re-rank";
938
+ }
939
+ });
940
+
852
941
  // src/core/navigators/PipelineDebugger.ts
853
942
  var PipelineDebugger_exports = {};
854
943
  __export(PipelineDebugger_exports, {
@@ -1917,12 +2006,13 @@ __export(elo_exports, {
1917
2006
  default: () => ELONavigator
1918
2007
  });
1919
2008
  import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
1920
- var ELONavigator;
2009
+ var ELO_RELEVANCE_SIGMA, ELONavigator;
1921
2010
  var init_elo = __esm({
1922
2011
  "src/core/navigators/generators/elo.ts"() {
1923
2012
  "use strict";
1924
2013
  init_navigators();
1925
2014
  init_logger();
2015
+ ELO_RELEVANCE_SIGMA = 300;
1926
2016
  ELONavigator = class extends ContentNavigator {
1927
2017
  /** Human-readable name for CardGenerator interface */
1928
2018
  name;
@@ -1962,8 +2052,8 @@ var init_elo = __esm({
1962
2052
  const scored = newCards.map((c) => {
1963
2053
  const cardElo = c.elo ?? 1e3;
1964
2054
  const distance = Math.abs(cardElo - userGlobalElo);
1965
- const rawScore = Math.max(0, 1 - distance / 500);
1966
- const samplingKey = rawScore > 0 ? Math.random() ** (1 / rawScore) : 0;
2055
+ const relevance = Math.exp(-((distance / ELO_RELEVANCE_SIGMA) ** 2));
2056
+ const samplingKey = relevance * (0.5 + 0.5 * Math.random());
1967
2057
  return {
1968
2058
  cardId: c.cardID,
1969
2059
  courseId: c.courseID,
@@ -1975,7 +2065,7 @@ var init_elo = __esm({
1975
2065
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
1976
2066
  action: "generated",
1977
2067
  score: samplingKey,
1978
- reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), raw ${rawScore.toFixed(3)}, key ${samplingKey.toFixed(3)}`
2068
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), relevance ${relevance.toFixed(3)}, key ${samplingKey.toFixed(3)}`
1979
2069
  }
1980
2070
  ]
1981
2071
  };
@@ -2043,7 +2133,7 @@ function shuffleInPlace(arr) {
2043
2133
  function pickTopByScore(cards, limit) {
2044
2134
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
2045
2135
  }
2046
- 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;
2136
+ var DEFAULT_FRESHNESS_WINDOW, DEFAULT_MAX_DIRECT_PER_RUN, DEFAULT_MAX_SUPPORT_PER_RUN, DEFAULT_HIERARCHY_DEPTH, DEFAULT_MIN_COUNT, DEFAULT_PRACTICE_MIN_COUNT, DEFAULT_MAX_PRACTICE_PER_RUN, BASE_TARGET_SCORE, BASE_SUPPORT_SCORE, DISCOVERED_SUPPORT_SCORE, BASE_PRACTICE_SCORE, MAX_TARGET_MULTIPLIER, MAX_SUPPORT_MULTIPLIER, PRESCRIBED_DEBUG_VERSION, PrescribedCardsGenerator;
2047
2137
  var init_prescribed = __esm({
2048
2138
  "src/core/navigators/generators/prescribed.ts"() {
2049
2139
  "use strict";
@@ -2054,9 +2144,12 @@ var init_prescribed = __esm({
2054
2144
  DEFAULT_MAX_SUPPORT_PER_RUN = 3;
2055
2145
  DEFAULT_HIERARCHY_DEPTH = 2;
2056
2146
  DEFAULT_MIN_COUNT = 3;
2147
+ DEFAULT_PRACTICE_MIN_COUNT = 3;
2148
+ DEFAULT_MAX_PRACTICE_PER_RUN = 4;
2057
2149
  BASE_TARGET_SCORE = 1;
2058
2150
  BASE_SUPPORT_SCORE = 0.8;
2059
2151
  DISCOVERED_SUPPORT_SCORE = 12;
2152
+ BASE_PRACTICE_SCORE = 1;
2060
2153
  MAX_TARGET_MULTIPLIER = 8;
2061
2154
  MAX_SUPPORT_MULTIPLIER = 4;
2062
2155
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -2164,7 +2257,18 @@ var init_prescribed = __esm({
2164
2257
  courseId,
2165
2258
  emittedIds
2166
2259
  );
2167
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
2260
+ const practiceCards = this.buildPracticeCards({
2261
+ group,
2262
+ courseId,
2263
+ emittedIds,
2264
+ cardsByTag,
2265
+ hierarchyConfigs,
2266
+ userTagElo,
2267
+ userGlobalElo,
2268
+ activeIds,
2269
+ seenIds
2270
+ });
2271
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
2168
2272
  }
2169
2273
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
2170
2274
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
@@ -2192,6 +2296,7 @@ var init_prescribed = __esm({
2192
2296
  const surfacedByGroup = /* @__PURE__ */ new Map();
2193
2297
  for (const card of finalCards) {
2194
2298
  const prov = card.provenance[0];
2299
+ if (prov?.reason.includes("mode=practice")) continue;
2195
2300
  const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
2196
2301
  const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
2197
2302
  if (!groupId) continue;
@@ -2261,7 +2366,12 @@ var init_prescribed = __esm({
2261
2366
  enabled: raw.hierarchyWalk?.enabled !== false,
2262
2367
  maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
2263
2368
  },
2264
- retireOnEncounter: raw.retireOnEncounter !== false
2369
+ retireOnEncounter: raw.retireOnEncounter !== false,
2370
+ practiceTagPatterns: dedupe(
2371
+ Array.isArray(raw.practiceTagPatterns) ? raw.practiceTagPatterns.filter((v) => typeof v === "string") : []
2372
+ ),
2373
+ practiceMinCount: typeof raw.practiceMinCount === "number" ? raw.practiceMinCount : DEFAULT_PRACTICE_MIN_COUNT,
2374
+ maxPracticeCardsPerRun: typeof raw.maxPracticeCardsPerRun === "number" ? raw.maxPracticeCardsPerRun : DEFAULT_MAX_PRACTICE_PER_RUN
2265
2375
  })).filter((g) => g.targetCardIds.length > 0);
2266
2376
  return { groups };
2267
2377
  } catch {
@@ -2484,6 +2594,92 @@ var init_prescribed = __esm({
2484
2594
  }
2485
2595
  return cards;
2486
2596
  }
2597
+ /**
2598
+ * Emit drill cards for *unlocked-but-under-practiced* skills.
2599
+ *
2600
+ * For each course tag matching the group's `practiceTagPatterns` that is both
2601
+ * unlocked (all hierarchy prerequisites met — i.e. the learner has been
2602
+ * introduced to it) and under-practiced (per-tag attempt count below
2603
+ * `practiceMinCount`), this resolves cards carrying that tag and emits them
2604
+ * into the candidate pool. It exists because global-ELO retrieval
2605
+ * systematically fails to fetch the (low-ELO) drill cards for a
2606
+ * freshly-introduced skill — putting them in the pool here lets the pipeline's
2607
+ * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2608
+ * this method's job; it only guarantees presence.
2609
+ *
2610
+ * Fully data-driven: the unlock relation comes from the hierarchy config and
2611
+ * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
2612
+ */
2613
+ buildPracticeCards(args) {
2614
+ const {
2615
+ group,
2616
+ courseId,
2617
+ emittedIds,
2618
+ cardsByTag,
2619
+ hierarchyConfigs,
2620
+ userTagElo,
2621
+ userGlobalElo,
2622
+ activeIds,
2623
+ seenIds
2624
+ } = args;
2625
+ const patterns = group.practiceTagPatterns ?? [];
2626
+ if (patterns.length === 0) return [];
2627
+ const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2628
+ const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2629
+ const practiceTags = [...cardsByTag.keys()].filter(
2630
+ (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2631
+ );
2632
+ if (practiceTags.length === 0) return [];
2633
+ const practiceCardIds = this.findDiscoveredSupportCards({
2634
+ supportTags: practiceTags,
2635
+ cardsByTag,
2636
+ activeIds,
2637
+ seenIds,
2638
+ excludedIds: emittedIds,
2639
+ limit: maxPractice
2640
+ });
2641
+ if (practiceCardIds.length === 0) return [];
2642
+ logger.info(
2643
+ `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2644
+ );
2645
+ const cards = [];
2646
+ for (const cardId of practiceCardIds) {
2647
+ emittedIds.add(cardId);
2648
+ cards.push({
2649
+ cardId,
2650
+ courseId,
2651
+ score: BASE_PRACTICE_SCORE,
2652
+ provenance: [
2653
+ {
2654
+ strategy: "prescribed",
2655
+ strategyName: this.strategyName || this.name,
2656
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2657
+ action: "generated",
2658
+ score: BASE_PRACTICE_SCORE,
2659
+ reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2660
+ }
2661
+ ]
2662
+ });
2663
+ }
2664
+ return cards;
2665
+ }
2666
+ /**
2667
+ * True for a skill that was *gated and is now reached*: it has at least one
2668
+ * declared hierarchy prerequisite set, and every set is fully satisfied by the
2669
+ * learner's per-tag ELO. This deliberately EXCLUDES tags with no prerequisites
2670
+ * — an ungated tag was never "introduced" in the curricular sense, so it isn't
2671
+ * a post-intro drill target (e.g. whole-word spelling tags that share the
2672
+ * `gpc:exercise:*` prefix but have no intro gate). Those are left to normal
2673
+ * ELO retrieval. This is the precise population the retrieval gap strands:
2674
+ * just-unlocked, low-ELO skills.
2675
+ */
2676
+ isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) {
2677
+ const prereqSets = hierarchyConfigs.map((hierarchy) => hierarchy.prerequisites[tag]).filter((prereqs) => Array.isArray(prereqs) && prereqs.length > 0);
2678
+ if (prereqSets.length === 0) return false;
2679
+ return prereqSets.every(
2680
+ (prereqs) => prereqs.every((pr) => this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo))
2681
+ );
2682
+ }
2487
2683
  findSupportCardsByTags(group, tagsByCard, supportTags) {
2488
2684
  if (supportTags.length === 0) {
2489
2685
  return [];
@@ -4278,7 +4474,7 @@ function logResultCards(cards) {
4278
4474
  for (let i = 0; i < cards.length; i++) {
4279
4475
  const c = cards[i];
4280
4476
  const tags = c.tags?.slice(0, 3).join(", ") || "";
4281
- const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
4477
+ 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) => {
4282
4478
  const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
4283
4479
  return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
4284
4480
  }).join(" | ");
@@ -4309,6 +4505,7 @@ var init_Pipeline = __esm({
4309
4505
  init_logger();
4310
4506
  init_orchestration();
4311
4507
  init_PipelineDebugger();
4508
+ init_diversityRerank();
4312
4509
  VERBOSE_RESULTS = true;
4313
4510
  Pipeline = class extends ContentNavigator {
4314
4511
  generator;
@@ -4482,6 +4679,7 @@ var init_Pipeline = __esm({
4482
4679
  this._ephemeralHints = null;
4483
4680
  cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
4484
4681
  }
4682
+ cards = diversityRerank(cards);
4485
4683
  cards.sort((a, b) => b.score - a.score);
4486
4684
  const tFilter = performance.now();
4487
4685
  const result = cards.slice(0, limit);
@@ -5050,6 +5248,7 @@ var init_3 = __esm({
5050
5248
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
5051
5249
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
5052
5250
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
5251
+ "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
5053
5252
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
5054
5253
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
5055
5254
  "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
@@ -5075,9 +5274,12 @@ var init_3 = __esm({
5075
5274
  var navigators_exports = {};
5076
5275
  __export(navigators_exports, {
5077
5276
  ContentNavigator: () => ContentNavigator,
5277
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
5278
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
5078
5279
  NavigatorRole: () => NavigatorRole,
5079
5280
  NavigatorRoles: () => NavigatorRoles,
5080
5281
  Navigators: () => Navigators,
5282
+ diversityRerank: () => diversityRerank,
5081
5283
  getCardOrigin: () => getCardOrigin,
5082
5284
  getRegisteredNavigator: () => getRegisteredNavigator,
5083
5285
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
@@ -5161,6 +5363,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
5161
5363
  var init_navigators = __esm({
5162
5364
  "src/core/navigators/index.ts"() {
5163
5365
  "use strict";
5366
+ init_diversityRerank();
5164
5367
  init_PipelineDebugger();
5165
5368
  init_logger();
5166
5369
  init_();
@@ -11220,8 +11423,17 @@ var ItemQueue = class {
11220
11423
  * Merge new items into the front of the queue, skipping duplicates.
11221
11424
  * Used by additive replans to inject high-quality candidates without
11222
11425
  * discarding the existing queue contents.
11426
+ *
11427
+ * `forceFrontIds` carries the mandatory (`+INF`) cards in this batch — a
11428
+ * durable `requireCard`/`requireTag` re-asserted by every replan. An ordinary
11429
+ * duplicate is left in place (skip), but a mandatory one that's *already*
11430
+ * queued is pulled out of its current slot so it rejoins at the front in batch
11431
+ * order. Without this, an additive merge unshifts fresh non-required cards
11432
+ * ahead of an already-present required card, steadily burying it until it never
11433
+ * gets drawn — defeating the "must appear" guarantee. Returns the count of
11434
+ * genuinely new cards added (re-fronted duplicates are not counted).
11223
11435
  */
11224
- mergeToFront(items, cardIdExtractor) {
11436
+ mergeToFront(items, cardIdExtractor, forceFrontIds) {
11225
11437
  let added = 0;
11226
11438
  const toInsert = [];
11227
11439
  for (const item of items) {
@@ -11230,6 +11442,11 @@ var ItemQueue = class {
11230
11442
  this.seenCardIds.push(cardId);
11231
11443
  toInsert.push(item);
11232
11444
  added++;
11445
+ } else if (forceFrontIds?.has(cardId)) {
11446
+ const idx = this.q.findIndex((qi) => cardIdExtractor(qi) === cardId);
11447
+ if (idx >= 0) {
11448
+ toInsert.push(...this.q.splice(idx, 1));
11449
+ }
11233
11450
  }
11234
11451
  }
11235
11452
  this.q.unshift(...toInsert);
@@ -13071,7 +13288,15 @@ var SPINNER_FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834"
13071
13288
  var spinnerFrame = 0;
13072
13289
  var overlayEl = null;
13073
13290
  var pollHandle = null;
13074
- var expanded = { reviewQ: false, newQ: false, failedQ: false };
13291
+ var lastSnapshot = null;
13292
+ var copyFlashUntil = 0;
13293
+ var minified = false;
13294
+ var expanded = {
13295
+ reviewQ: false,
13296
+ newQ: false,
13297
+ failedQ: false,
13298
+ drawn: false
13299
+ };
13075
13300
  function toggleSessionOverlay() {
13076
13301
  if (typeof document === "undefined") {
13077
13302
  logger.info("[Session Overlay] No DOM available (non-browser host); overlay unavailable.");
@@ -13086,6 +13311,7 @@ function toggleSessionOverlay() {
13086
13311
  }
13087
13312
  }
13088
13313
  function mount() {
13314
+ minified = false;
13089
13315
  overlayEl = document.createElement("div");
13090
13316
  overlayEl.id = OVERLAY_ID;
13091
13317
  Object.assign(overlayEl.style, {
@@ -13124,11 +13350,23 @@ function render() {
13124
13350
  spinnerFrame++;
13125
13351
  const ctrl = getActiveController();
13126
13352
  if (!ctrl) {
13127
- overlayEl.innerHTML = headerHtml() + `<div style="opacity:.65">No active session.</div>`;
13353
+ lastSnapshot = null;
13354
+ overlayEl.innerHTML = headerHtml() + (minified ? "" : `<div style="opacity:.65">No active session.</div>`);
13355
+ attachHandlers();
13128
13356
  return;
13129
13357
  }
13130
13358
  const s = ctrl.getDebugSnapshot();
13131
- 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);
13359
+ lastSnapshot = s;
13360
+ if (minified) {
13361
+ overlayEl.innerHTML = headerHtml();
13362
+ attachHandlers();
13363
+ return;
13364
+ }
13365
+ 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);
13366
+ attachHandlers();
13367
+ }
13368
+ function attachHandlers() {
13369
+ if (!overlayEl) return;
13132
13370
  overlayEl.querySelectorAll("[data-q]").forEach((el) => {
13133
13371
  el.onclick = () => {
13134
13372
  const key = el.dataset.q;
@@ -13137,9 +13375,45 @@ function render() {
13137
13375
  render();
13138
13376
  };
13139
13377
  });
13378
+ const copyBtn = overlayEl.querySelector("[data-copy]");
13379
+ if (copyBtn) {
13380
+ copyBtn.onclick = (ev) => {
13381
+ ev.stopPropagation();
13382
+ copySnapshot();
13383
+ };
13384
+ }
13385
+ const minBtn = overlayEl.querySelector("[data-min]");
13386
+ if (minBtn) {
13387
+ minBtn.onclick = (ev) => {
13388
+ ev.stopPropagation();
13389
+ minified = !minified;
13390
+ render();
13391
+ };
13392
+ }
13393
+ }
13394
+ function copySnapshot() {
13395
+ const text = snapshotToText(lastSnapshot);
13396
+ const flash = () => {
13397
+ copyFlashUntil = Date.now() + 1200;
13398
+ render();
13399
+ };
13400
+ const clip = typeof navigator !== "undefined" ? navigator.clipboard : void 0;
13401
+ if (clip?.writeText) {
13402
+ clip.writeText(text).then(flash, (err) => {
13403
+ logger.warn(`[Session Overlay] Clipboard write failed: ${String(err)}`);
13404
+ });
13405
+ } else {
13406
+ logger.info(`[Session Overlay] Clipboard unavailable; snapshot follows:
13407
+ ${text}`);
13408
+ }
13140
13409
  }
13141
13410
  function headerHtml() {
13142
- return `<div style="font-weight:600;color:#93c5fd;margin-bottom:4px">\u2699 SessionController</div>`;
13411
+ const flashing = Date.now() < copyFlashUntil;
13412
+ const btnLabel = flashing ? "\u2713 copied" : "\u2398 copy";
13413
+ const btnColor = flashing ? "#86efac" : "#93c5fd";
13414
+ 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>`;
13415
+ 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>`;
13416
+ return `<div style="font-weight:600;color:#93c5fd;margin-bottom:${minified ? 0 : 4}px">${copyBtn}${minBtn}\u2699 SessionController</div>`;
13143
13417
  }
13144
13418
  function replanHtml(s) {
13145
13419
  if (!s.replanActive) {
@@ -13182,22 +13456,98 @@ function hintsHtml(h) {
13182
13456
  }
13183
13457
  function queueHtml(key, label, q) {
13184
13458
  const collapsible = q.length > INLINE_THRESHOLD;
13185
- const isOpen = !collapsible || expanded[key];
13459
+ const isOpen = collapsible && expanded[key];
13186
13460
  const caret = collapsible ? expanded[key] ? "\u25BE " : "\u25B8 " : "";
13187
13461
  const drawn = q.dequeueCount ? ` <span style="opacity:.5">drawn ${q.dequeueCount}</span>` : "";
13188
13462
  const titleStyle = collapsible ? "cursor:pointer;color:#f9a8d4" : "color:#f9a8d4";
13189
13463
  const titleAttr = collapsible ? ` data-q="${key}"` : "";
13190
13464
  const title = `<div${titleAttr} style="${titleStyle}">${caret}${label}: ${q.length}${drawn}</div>`;
13191
- let body = "";
13192
- if (isOpen && q.cards.length) {
13193
- 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>`;
13194
- } else if (!q.cards.length) {
13195
- body = `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
13196
- } else {
13197
- body = `<div style="margin:1px 0 6px 6px;opacity:.55">(${q.length} cards \u2014 click to expand)</div>`;
13465
+ if (!q.cards.length) {
13466
+ return title + `<div style="margin:1px 0 6px 6px;opacity:.5">empty</div>`;
13467
+ }
13468
+ const shown = isOpen ? q.cards : q.cards.slice(0, INLINE_THRESHOLD);
13469
+ const hiddenCount = q.length - shown.length;
13470
+ const listMarginBottom = collapsible ? 2 : 6;
13471
+ let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">` + shown.map((c) => `<li style="white-space:nowrap">${esc(c)}</li>`).join("") + `</ol>`;
13472
+ if (collapsible) {
13473
+ const footer = isOpen ? "\u25BE show less" : `\u2026 +${hiddenCount} more`;
13474
+ body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
13198
13475
  }
13199
13476
  return title + body;
13200
13477
  }
13478
+ function outcomeGlyph(correct) {
13479
+ if (correct === true) return `<span style="color:#86efac">\u2713</span>`;
13480
+ if (correct === false) return `<span style="color:#fca5a5">\u2717</span>`;
13481
+ return `<span style="opacity:.5">\xB7</span>`;
13482
+ }
13483
+ function drawnHtml(key, drawn) {
13484
+ const collapsible = drawn.length > INLINE_THRESHOLD;
13485
+ const isOpen = collapsible && expanded[key];
13486
+ const caret = collapsible ? expanded[key] ? "\u25BE " : "\u25B8 " : "";
13487
+ const titleStyle = collapsible ? "cursor:pointer;color:#c4b5fd" : "color:#c4b5fd";
13488
+ const titleAttr = collapsible ? ` data-q="${key}"` : "";
13489
+ const title = `<div${titleAttr} style="${titleStyle}">${caret}drawn: ${drawn.length}</div>`;
13490
+ if (!drawn.length) {
13491
+ return title + `<div style="margin:1px 0 6px 6px;opacity:.5">none yet</div>`;
13492
+ }
13493
+ const shown = isOpen ? drawn : drawn.slice(0, INLINE_THRESHOLD);
13494
+ const hiddenCount = drawn.length - shown.length;
13495
+ const listMarginBottom = collapsible ? 2 : 6;
13496
+ const rows = shown.map((d) => {
13497
+ const retries = d.attempts > 1 ? `<span style="opacity:.5"> \xD7${d.attempts}</span>` : "";
13498
+ const time = `<span style="opacity:.45"> ${Math.round(d.timeSpentMs / 100) / 10}s</span>`;
13499
+ return `<li style="white-space:nowrap">${outcomeGlyph(d.correct)} ${esc(d.cardID)}<span style="opacity:.5"> [${esc(d.status)}]</span>${retries}${time}</li>`;
13500
+ }).join("");
13501
+ let body = `<ol style="margin:2px 0 ${listMarginBottom}px 0;padding-left:20px">${rows}</ol>`;
13502
+ if (collapsible) {
13503
+ const footer = isOpen ? "\u25BE show less" : `\u2026 +${hiddenCount} more`;
13504
+ body += `<div data-q="${key}" style="cursor:pointer;margin:0 0 6px 20px;opacity:.6">${footer}</div>`;
13505
+ }
13506
+ return title + body;
13507
+ }
13508
+ function snapshotToText(s) {
13509
+ if (!s) return "SessionController \u2014 no active session.";
13510
+ const lines = [];
13511
+ lines.push("=== SessionController ===");
13512
+ lines.push(`time ${formatTime(s.secondsRemaining)}`);
13513
+ if (s.hasCardGuarantee) lines.push(`guarantee: ${s.minCardsGuarantee}`);
13514
+ lines.push(`well-indicated left: ${s.wellIndicatedRemaining}`);
13515
+ lines.push(`current: ${s.currentCard ?? "\u2014"}`);
13516
+ lines.push(
13517
+ s.replanActive ? `replan: ACTIVE [${s.replanLabel ?? "(auto)"}]` : "replan: idle"
13518
+ );
13519
+ lines.push("");
13520
+ lines.push("sessionHints:");
13521
+ const h = s.sessionHints;
13522
+ const hintParts = [];
13523
+ if (h) {
13524
+ if (h.boostTags && Object.keys(h.boostTags).length)
13525
+ hintParts.push(` boost: ${Object.entries(h.boostTags).map(([k, v]) => `${k}\xD7${v}`).join(", ")}`);
13526
+ if (h.boostCards && Object.keys(h.boostCards).length)
13527
+ hintParts.push(` boostCards: ${Object.entries(h.boostCards).map(([k, v]) => `${k}\xD7${v}`).join(", ")}`);
13528
+ if (h.requireCards?.length) hintParts.push(` require: ${h.requireCards.join(", ")}`);
13529
+ if (h.requireTags?.length) hintParts.push(` requireTags: ${h.requireTags.join(", ")}`);
13530
+ if (h.excludeTags?.length) hintParts.push(` exclude: ${h.excludeTags.join(", ")}`);
13531
+ if (h.excludeCards?.length) hintParts.push(` excludeCards: ${h.excludeCards.join(", ")}`);
13532
+ }
13533
+ lines.push(hintParts.length ? hintParts.join("\n") : " none");
13534
+ const queueText = (label, q) => {
13535
+ lines.push("");
13536
+ lines.push(`${label}: ${q.length} (drawn ${q.dequeueCount})`);
13537
+ q.cards.forEach((c, i) => lines.push(` ${i + 1}. ${c}`));
13538
+ };
13539
+ queueText("reviewQ", s.reviewQ);
13540
+ queueText("newQ", s.newQ);
13541
+ queueText("failedQ", s.failedQ);
13542
+ lines.push("");
13543
+ lines.push(`drawn: ${s.drawnCards.length}`);
13544
+ s.drawnCards.forEach((d, i) => {
13545
+ const mark = d.correct === true ? "\u2713" : d.correct === false ? "\u2717" : "\xB7";
13546
+ const time = `${Math.round(d.timeSpentMs / 100) / 10}s`;
13547
+ lines.push(` ${i + 1}. ${mark} ${d.cardID} [${d.status}] \xD7${d.attempts} ${time}`);
13548
+ });
13549
+ return lines.join("\n");
13550
+ }
13201
13551
  function formatTime(totalSeconds) {
13202
13552
  const s = Math.max(0, Math.round(totalSeconds));
13203
13553
  const m = Math.floor(s / 60);
@@ -13567,6 +13917,21 @@ var SessionController = class _SessionController extends Loggable {
13567
13917
  * recomputed per-run in `_runReplan` and would otherwise go stale.
13568
13918
  */
13569
13919
  _sessionHints = null;
13920
+ /**
13921
+ * Card IDs that have been *served* (drawn/consumed) this session. Populated
13922
+ * at the single consumption choke-point (removeItemFromQueue), so it reflects
13923
+ * a draw the instant it happens — earlier than `_sessionRecord`, which only
13924
+ * lands once the card is *responded to*.
13925
+ *
13926
+ * Used to keep already-served cards out of newQ on every (re)plan: a `new`
13927
+ * card shown once must never re-enter newQ this session. This is the general
13928
+ * guard against re-presentation — including the case where a replan in flight
13929
+ * captured a now-drawn card (e.g. a +INF require-injected follow-up the
13930
+ * depletion prefetch grabbed just before it was drawn). Reviews/failed cards
13931
+ * legitimately recur and are tracked by their own queues, so this only gates
13932
+ * `new`-origin candidates.
13933
+ */
13934
+ _servedCardIds = /* @__PURE__ */ new Set();
13570
13935
  /**
13571
13936
  * Consumer-supplied hooks invoked after each question response is processed.
13572
13937
  * Seeded from constructor options (threaded from
@@ -13776,6 +14141,7 @@ var SessionController = class _SessionController extends Loggable {
13776
14141
  if (opts.mode && opts.mode !== "replace") return true;
13777
14142
  if (opts.hints && Object.keys(opts.hints).length > 0) return true;
13778
14143
  if (opts.sessionHints !== void 0) return true;
14144
+ if (opts.mergeSessionHints !== void 0) return true;
13779
14145
  return false;
13780
14146
  }
13781
14147
  /**
@@ -13811,6 +14177,10 @@ var SessionController = class _SessionController extends Loggable {
13811
14177
  `[Replan] Session hints ${opts.sessionHints ? "set" : "cleared"}: ${JSON.stringify(opts.sessionHints)}`
13812
14178
  );
13813
14179
  }
14180
+ if (opts.mergeSessionHints !== void 0) {
14181
+ this._sessionHints = mergeHints2([this._sessionHints, opts.mergeSessionHints]) ?? null;
14182
+ this.log(`[Replan] Session hints merged: ${JSON.stringify(this._sessionHints)}`);
14183
+ }
13814
14184
  this._applyHintsToSources(opts.hints, opts.label);
13815
14185
  const labelTag = opts.label ? ` [${opts.label}]` : "";
13816
14186
  this.log(
@@ -13863,6 +14233,16 @@ var SessionController = class _SessionController extends Loggable {
13863
14233
  }
13864
14234
  return { length: q.length, dequeueCount: q.dequeueCount, cards };
13865
14235
  };
14236
+ const drawnCards = this._sessionRecord.map((r) => {
14237
+ const last = r.records[r.records.length - 1];
14238
+ return {
14239
+ cardID: r.item.cardID,
14240
+ status: r.item.status,
14241
+ attempts: r.records.length,
14242
+ correct: last && isQuestionRecord(last) ? last.isCorrect : null,
14243
+ timeSpentMs: r.records.reduce((sum, rec) => sum + rec.timeSpent, 0)
14244
+ };
14245
+ });
13866
14246
  return {
13867
14247
  secondsRemaining: this.secondsRemaining,
13868
14248
  hasCardGuarantee: this.hasCardGuarantee,
@@ -13874,7 +14254,8 @@ var SessionController = class _SessionController extends Loggable {
13874
14254
  replanLabel: this._activeReplanLabel,
13875
14255
  reviewQ: describe(this.reviewQ),
13876
14256
  newQ: describe(this.newQ),
13877
- failedQ: describe(this.failedQ)
14257
+ failedQ: describe(this.failedQ),
14258
+ drawnCards
13878
14259
  };
13879
14260
  }
13880
14261
  /**
@@ -14183,7 +14564,16 @@ var SessionController = class _SessionController extends Loggable {
14183
14564
  mixedWeighted
14184
14565
  );
14185
14566
  const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "review").slice(0, this._initialReviewCap);
14186
- const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === "new").slice(0, newLimit);
14567
+ const newCandidates = mixedWeighted.filter(
14568
+ (w) => getCardOrigin(w) === "new" && !this._servedCardIds.has(w.cardId)
14569
+ );
14570
+ const mandatoryWeighted = newCandidates.filter((w) => w.score === Number.POSITIVE_INFINITY);
14571
+ const optionalWeighted = newCandidates.filter((w) => w.score !== Number.POSITIVE_INFINITY);
14572
+ const newWeighted = [
14573
+ ...mandatoryWeighted,
14574
+ ...optionalWeighted.slice(0, Math.max(0, newLimit - mandatoryWeighted.length))
14575
+ ];
14576
+ const mandatoryIds = new Set(mandatoryWeighted.map((w) => w.cardId));
14187
14577
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
14188
14578
  let report = replan ? "Replan content:\n" : "Mixed content session created with:\n";
14189
14579
  if (!replan) {
@@ -14218,7 +14608,7 @@ var SessionController = class _SessionController extends Loggable {
14218
14608
  `;
14219
14609
  }
14220
14610
  if (additive) {
14221
- const added = this.newQ.mergeToFront(newItems, (item) => item.cardID);
14611
+ const added = this.newQ.mergeToFront(newItems, (item) => item.cardID, mandatoryIds);
14222
14612
  report += `Additive merge: ${added} new cards added to front of newQ
14223
14613
  `;
14224
14614
  } else if (replan) {
@@ -14320,7 +14710,7 @@ var SessionController = class _SessionController extends Loggable {
14320
14710
  this.log(
14321
14711
  `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) (${otherContent} in other queues) with ${this._secondsRemaining}s remaining. Triggering background replan.`
14322
14712
  );
14323
- void this.requestReplan({ label: "auto:depletion" });
14713
+ void this.requestReplan({ label: "auto:depletion", mode: "merge" });
14324
14714
  }
14325
14715
  const REPLAN_BUFFER = 3;
14326
14716
  if (!this._suppressQualityReplan && this._wellIndicatedRemaining <= REPLAN_BUFFER && this.newQ.length > 0 && !this._replanPromise) {
@@ -14461,6 +14851,8 @@ var SessionController = class _SessionController extends Loggable {
14461
14851
  * Remove an item from its source queue after consumption by nextCard().
14462
14852
  */
14463
14853
  removeItemFromQueue(item) {
14854
+ this._clearDurableRequirement(item.cardID);
14855
+ this._servedCardIds.add(item.cardID);
14464
14856
  if (this.reviewQ.peek(0)?.cardID === item.cardID) {
14465
14857
  this.reviewQ.dequeue((queueItem) => queueItem.cardID);
14466
14858
  } else if (this.newQ.peek(0)?.cardID === item.cardID) {
@@ -14472,6 +14864,27 @@ var SessionController = class _SessionController extends Loggable {
14472
14864
  this.failedQ.dequeue((queueItem) => queueItem.cardID);
14473
14865
  }
14474
14866
  }
14867
+ /**
14868
+ * Remove a satisfied card ID from the durable session-hint `requireCards`
14869
+ * list. Called when a card is consumed (see removeItemFromQueue). No-op if
14870
+ * the card was not a durable requirement.
14871
+ *
14872
+ * Matches literal IDs only: a glob/pattern requirement (which may stand for
14873
+ * several cards) is NOT considered satisfied by a single draw and is left in
14874
+ * place — durable patterns are the caller's responsibility, one-shot `hints`
14875
+ * remain the right tool for them.
14876
+ */
14877
+ _clearDurableRequirement(cardID) {
14878
+ const req = this._sessionHints?.requireCards;
14879
+ if (!req || req.length === 0) return;
14880
+ const next = req.filter((id) => id !== cardID);
14881
+ if (next.length === req.length) return;
14882
+ this._sessionHints = {
14883
+ ...this._sessionHints,
14884
+ requireCards: next.length > 0 ? next : void 0
14885
+ };
14886
+ this.log(`[Replan] Durable requirement satisfied & cleared on draw: ${cardID}`);
14887
+ }
14475
14888
  /**
14476
14889
  * End the session and record learning outcomes.
14477
14890
  *
@@ -14526,6 +14939,8 @@ export {
14526
14939
  ContentNavigator,
14527
14940
  CouchDBToStaticPacker,
14528
14941
  CourseLookup,
14942
+ DIVERSITY_FLOOR,
14943
+ DIVERSITY_STRENGTH,
14529
14944
  DocType,
14530
14945
  DocTypePrefixes,
14531
14946
  ENV,
@@ -14551,6 +14966,7 @@ export {
14551
14966
  computeSpread,
14552
14967
  computeStrategyGradient,
14553
14968
  createOrchestrationContext,
14969
+ diversityRerank,
14554
14970
  docIsDeleted,
14555
14971
  endSessionTracking,
14556
14972
  ensureAppDataDirectory,