@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.
@@ -498,6 +498,95 @@ var init_courseLookupDB = __esm({
498
498
  }
499
499
  });
500
500
 
501
+ // src/core/navigators/diversityRerank.ts
502
+ var diversityRerank_exports = {};
503
+ __export(diversityRerank_exports, {
504
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
505
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
506
+ diversityRerank: () => diversityRerank
507
+ });
508
+ function diversityRerank(cards, opts = {}) {
509
+ const strength = opts.strength ?? DIVERSITY_STRENGTH;
510
+ const floor = opts.floor ?? DIVERSITY_FLOOR;
511
+ const n = cards.length;
512
+ if (n <= 1) return cards;
513
+ const df = /* @__PURE__ */ new Map();
514
+ for (const card of cards) {
515
+ for (const tag of card.tags ?? []) {
516
+ df.set(tag, (df.get(tag) ?? 0) + 1);
517
+ }
518
+ }
519
+ const idf = /* @__PURE__ */ new Map();
520
+ for (const [tag, freq] of df) {
521
+ idf.set(tag, Math.log(n / freq));
522
+ }
523
+ const remaining = [...cards];
524
+ const emittedCount = /* @__PURE__ */ new Map();
525
+ const out = [];
526
+ const repetitionLoad = (card) => {
527
+ let load = 0;
528
+ for (const tag of card.tags ?? []) {
529
+ const seen = emittedCount.get(tag);
530
+ if (seen) load += (idf.get(tag) ?? 0) * seen;
531
+ }
532
+ return load;
533
+ };
534
+ while (remaining.length > 0) {
535
+ let bestIdx = 0;
536
+ let bestValue = -Infinity;
537
+ let bestPenalty = 1;
538
+ let bestLoad = 0;
539
+ for (let i = 0; i < remaining.length; i++) {
540
+ const card = remaining[i];
541
+ const load = repetitionLoad(card);
542
+ const penalty = load > 0 ? Math.max(floor, 1 / (1 + strength * load)) : 1;
543
+ const value = card.score * penalty;
544
+ if (value > bestValue) {
545
+ bestValue = value;
546
+ bestIdx = i;
547
+ bestPenalty = penalty;
548
+ bestLoad = load;
549
+ }
550
+ }
551
+ const [picked] = remaining.splice(bestIdx, 1);
552
+ if (Number.isFinite(picked.score) && bestPenalty < 1) {
553
+ const newScore = picked.score * bestPenalty;
554
+ out.push({
555
+ ...picked,
556
+ score: newScore,
557
+ provenance: [
558
+ ...picked.provenance,
559
+ {
560
+ strategy: STRATEGY,
561
+ strategyId: STRATEGY_ID,
562
+ strategyName: STRATEGY_NAME,
563
+ action: "penalized",
564
+ score: newScore,
565
+ reason: `repeated tags (load ${bestLoad.toFixed(2)}) \u2192 \xD7${bestPenalty.toFixed(2)}`
566
+ }
567
+ ]
568
+ });
569
+ } else {
570
+ out.push(picked);
571
+ }
572
+ for (const tag of picked.tags ?? []) {
573
+ emittedCount.set(tag, (emittedCount.get(tag) ?? 0) + 1);
574
+ }
575
+ }
576
+ return out;
577
+ }
578
+ var DIVERSITY_STRENGTH, DIVERSITY_FLOOR, STRATEGY, STRATEGY_ID, STRATEGY_NAME;
579
+ var init_diversityRerank = __esm({
580
+ "src/core/navigators/diversityRerank.ts"() {
581
+ "use strict";
582
+ DIVERSITY_STRENGTH = 0.6;
583
+ DIVERSITY_FLOOR = 0.3;
584
+ STRATEGY = "diversityRerank";
585
+ STRATEGY_ID = "DIVERSITY_RERANK";
586
+ STRATEGY_NAME = "Diversity Re-rank";
587
+ }
588
+ });
589
+
501
590
  // src/core/navigators/PipelineDebugger.ts
502
591
  var PipelineDebugger_exports = {};
503
592
  __export(PipelineDebugger_exports, {
@@ -1566,12 +1655,13 @@ __export(elo_exports, {
1566
1655
  default: () => ELONavigator
1567
1656
  });
1568
1657
  import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
1569
- var ELONavigator;
1658
+ var ELO_RELEVANCE_SIGMA, ELONavigator;
1570
1659
  var init_elo = __esm({
1571
1660
  "src/core/navigators/generators/elo.ts"() {
1572
1661
  "use strict";
1573
1662
  init_navigators();
1574
1663
  init_logger();
1664
+ ELO_RELEVANCE_SIGMA = 300;
1575
1665
  ELONavigator = class extends ContentNavigator {
1576
1666
  /** Human-readable name for CardGenerator interface */
1577
1667
  name;
@@ -1611,8 +1701,8 @@ var init_elo = __esm({
1611
1701
  const scored = newCards.map((c) => {
1612
1702
  const cardElo = c.elo ?? 1e3;
1613
1703
  const distance = Math.abs(cardElo - userGlobalElo);
1614
- const rawScore = Math.max(0, 1 - distance / 500);
1615
- const samplingKey = rawScore > 0 ? Math.random() ** (1 / rawScore) : 0;
1704
+ const relevance = Math.exp(-((distance / ELO_RELEVANCE_SIGMA) ** 2));
1705
+ const samplingKey = relevance * (0.5 + 0.5 * Math.random());
1616
1706
  return {
1617
1707
  cardId: c.cardID,
1618
1708
  courseId: c.courseID,
@@ -1624,7 +1714,7 @@ var init_elo = __esm({
1624
1714
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
1625
1715
  action: "generated",
1626
1716
  score: samplingKey,
1627
- reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), raw ${rawScore.toFixed(3)}, key ${samplingKey.toFixed(3)}`
1717
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), relevance ${relevance.toFixed(3)}, key ${samplingKey.toFixed(3)}`
1628
1718
  }
1629
1719
  ]
1630
1720
  };
@@ -1692,7 +1782,7 @@ function shuffleInPlace(arr) {
1692
1782
  function pickTopByScore(cards, limit) {
1693
1783
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1694
1784
  }
1695
- 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;
1785
+ 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;
1696
1786
  var init_prescribed = __esm({
1697
1787
  "src/core/navigators/generators/prescribed.ts"() {
1698
1788
  "use strict";
@@ -1703,9 +1793,12 @@ var init_prescribed = __esm({
1703
1793
  DEFAULT_MAX_SUPPORT_PER_RUN = 3;
1704
1794
  DEFAULT_HIERARCHY_DEPTH = 2;
1705
1795
  DEFAULT_MIN_COUNT = 3;
1796
+ DEFAULT_PRACTICE_MIN_COUNT = 3;
1797
+ DEFAULT_MAX_PRACTICE_PER_RUN = 4;
1706
1798
  BASE_TARGET_SCORE = 1;
1707
1799
  BASE_SUPPORT_SCORE = 0.8;
1708
1800
  DISCOVERED_SUPPORT_SCORE = 12;
1801
+ BASE_PRACTICE_SCORE = 1;
1709
1802
  MAX_TARGET_MULTIPLIER = 8;
1710
1803
  MAX_SUPPORT_MULTIPLIER = 4;
1711
1804
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -1813,7 +1906,18 @@ var init_prescribed = __esm({
1813
1906
  courseId,
1814
1907
  emittedIds
1815
1908
  );
1816
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
1909
+ const practiceCards = this.buildPracticeCards({
1910
+ group,
1911
+ courseId,
1912
+ emittedIds,
1913
+ cardsByTag,
1914
+ hierarchyConfigs,
1915
+ userTagElo,
1916
+ userGlobalElo,
1917
+ activeIds,
1918
+ seenIds
1919
+ });
1920
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
1817
1921
  }
1818
1922
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
1819
1923
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
@@ -1841,6 +1945,7 @@ var init_prescribed = __esm({
1841
1945
  const surfacedByGroup = /* @__PURE__ */ new Map();
1842
1946
  for (const card of finalCards) {
1843
1947
  const prov = card.provenance[0];
1948
+ if (prov?.reason.includes("mode=practice")) continue;
1844
1949
  const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
1845
1950
  const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
1846
1951
  if (!groupId) continue;
@@ -1910,7 +2015,12 @@ var init_prescribed = __esm({
1910
2015
  enabled: raw.hierarchyWalk?.enabled !== false,
1911
2016
  maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
1912
2017
  },
1913
- retireOnEncounter: raw.retireOnEncounter !== false
2018
+ retireOnEncounter: raw.retireOnEncounter !== false,
2019
+ practiceTagPatterns: dedupe(
2020
+ Array.isArray(raw.practiceTagPatterns) ? raw.practiceTagPatterns.filter((v) => typeof v === "string") : []
2021
+ ),
2022
+ practiceMinCount: typeof raw.practiceMinCount === "number" ? raw.practiceMinCount : DEFAULT_PRACTICE_MIN_COUNT,
2023
+ maxPracticeCardsPerRun: typeof raw.maxPracticeCardsPerRun === "number" ? raw.maxPracticeCardsPerRun : DEFAULT_MAX_PRACTICE_PER_RUN
1914
2024
  })).filter((g) => g.targetCardIds.length > 0);
1915
2025
  return { groups };
1916
2026
  } catch {
@@ -2133,6 +2243,92 @@ var init_prescribed = __esm({
2133
2243
  }
2134
2244
  return cards;
2135
2245
  }
2246
+ /**
2247
+ * Emit drill cards for *unlocked-but-under-practiced* skills.
2248
+ *
2249
+ * For each course tag matching the group's `practiceTagPatterns` that is both
2250
+ * unlocked (all hierarchy prerequisites met — i.e. the learner has been
2251
+ * introduced to it) and under-practiced (per-tag attempt count below
2252
+ * `practiceMinCount`), this resolves cards carrying that tag and emits them
2253
+ * into the candidate pool. It exists because global-ELO retrieval
2254
+ * systematically fails to fetch the (low-ELO) drill cards for a
2255
+ * freshly-introduced skill — putting them in the pool here lets the pipeline's
2256
+ * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2257
+ * this method's job; it only guarantees presence.
2258
+ *
2259
+ * Fully data-driven: the unlock relation comes from the hierarchy config and
2260
+ * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
2261
+ */
2262
+ buildPracticeCards(args) {
2263
+ const {
2264
+ group,
2265
+ courseId,
2266
+ emittedIds,
2267
+ cardsByTag,
2268
+ hierarchyConfigs,
2269
+ userTagElo,
2270
+ userGlobalElo,
2271
+ activeIds,
2272
+ seenIds
2273
+ } = args;
2274
+ const patterns = group.practiceTagPatterns ?? [];
2275
+ if (patterns.length === 0) return [];
2276
+ const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2277
+ const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2278
+ const practiceTags = [...cardsByTag.keys()].filter(
2279
+ (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2280
+ );
2281
+ if (practiceTags.length === 0) return [];
2282
+ const practiceCardIds = this.findDiscoveredSupportCards({
2283
+ supportTags: practiceTags,
2284
+ cardsByTag,
2285
+ activeIds,
2286
+ seenIds,
2287
+ excludedIds: emittedIds,
2288
+ limit: maxPractice
2289
+ });
2290
+ if (practiceCardIds.length === 0) return [];
2291
+ logger.info(
2292
+ `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2293
+ );
2294
+ const cards = [];
2295
+ for (const cardId of practiceCardIds) {
2296
+ emittedIds.add(cardId);
2297
+ cards.push({
2298
+ cardId,
2299
+ courseId,
2300
+ score: BASE_PRACTICE_SCORE,
2301
+ provenance: [
2302
+ {
2303
+ strategy: "prescribed",
2304
+ strategyName: this.strategyName || this.name,
2305
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2306
+ action: "generated",
2307
+ score: BASE_PRACTICE_SCORE,
2308
+ reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2309
+ }
2310
+ ]
2311
+ });
2312
+ }
2313
+ return cards;
2314
+ }
2315
+ /**
2316
+ * True for a skill that was *gated and is now reached*: it has at least one
2317
+ * declared hierarchy prerequisite set, and every set is fully satisfied by the
2318
+ * learner's per-tag ELO. This deliberately EXCLUDES tags with no prerequisites
2319
+ * — an ungated tag was never "introduced" in the curricular sense, so it isn't
2320
+ * a post-intro drill target (e.g. whole-word spelling tags that share the
2321
+ * `gpc:exercise:*` prefix but have no intro gate). Those are left to normal
2322
+ * ELO retrieval. This is the precise population the retrieval gap strands:
2323
+ * just-unlocked, low-ELO skills.
2324
+ */
2325
+ isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) {
2326
+ const prereqSets = hierarchyConfigs.map((hierarchy) => hierarchy.prerequisites[tag]).filter((prereqs) => Array.isArray(prereqs) && prereqs.length > 0);
2327
+ if (prereqSets.length === 0) return false;
2328
+ return prereqSets.every(
2329
+ (prereqs) => prereqs.every((pr) => this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo))
2330
+ );
2331
+ }
2136
2332
  findSupportCardsByTags(group, tagsByCard, supportTags) {
2137
2333
  if (supportTags.length === 0) {
2138
2334
  return [];
@@ -3681,7 +3877,7 @@ function logResultCards(cards) {
3681
3877
  for (let i = 0; i < cards.length; i++) {
3682
3878
  const c = cards[i];
3683
3879
  const tags = c.tags?.slice(0, 3).join(", ") || "";
3684
- const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
3880
+ 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) => {
3685
3881
  const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
3686
3882
  return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
3687
3883
  }).join(" | ");
@@ -3712,6 +3908,7 @@ var init_Pipeline = __esm({
3712
3908
  init_logger();
3713
3909
  init_orchestration();
3714
3910
  init_PipelineDebugger();
3911
+ init_diversityRerank();
3715
3912
  VERBOSE_RESULTS = true;
3716
3913
  Pipeline = class extends ContentNavigator {
3717
3914
  generator;
@@ -3885,6 +4082,7 @@ var init_Pipeline = __esm({
3885
4082
  this._ephemeralHints = null;
3886
4083
  cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
3887
4084
  }
4085
+ cards = diversityRerank(cards);
3888
4086
  cards.sort((a, b) => b.score - a.score);
3889
4087
  const tFilter = performance.now();
3890
4088
  const result = cards.slice(0, limit);
@@ -4453,6 +4651,7 @@ var init_3 = __esm({
4453
4651
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
4454
4652
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
4455
4653
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
4654
+ "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
4456
4655
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
4457
4656
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
4458
4657
  "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
@@ -4478,9 +4677,12 @@ var init_3 = __esm({
4478
4677
  var navigators_exports = {};
4479
4678
  __export(navigators_exports, {
4480
4679
  ContentNavigator: () => ContentNavigator,
4680
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
4681
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
4481
4682
  NavigatorRole: () => NavigatorRole,
4482
4683
  NavigatorRoles: () => NavigatorRoles,
4483
4684
  Navigators: () => Navigators,
4685
+ diversityRerank: () => diversityRerank,
4484
4686
  getCardOrigin: () => getCardOrigin,
4485
4687
  getRegisteredNavigator: () => getRegisteredNavigator,
4486
4688
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
@@ -4564,6 +4766,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
4564
4766
  var init_navigators = __esm({
4565
4767
  "src/core/navigators/index.ts"() {
4566
4768
  "use strict";
4769
+ init_diversityRerank();
4567
4770
  init_PipelineDebugger();
4568
4771
  init_logger();
4569
4772
  init_();