@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.
@@ -621,6 +621,95 @@ var init_courseLookupDB = __esm({
621
621
  }
622
622
  });
623
623
 
624
+ // src/core/navigators/diversityRerank.ts
625
+ var diversityRerank_exports = {};
626
+ __export(diversityRerank_exports, {
627
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
628
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
629
+ diversityRerank: () => diversityRerank
630
+ });
631
+ function diversityRerank(cards, opts = {}) {
632
+ const strength = opts.strength ?? DIVERSITY_STRENGTH;
633
+ const floor = opts.floor ?? DIVERSITY_FLOOR;
634
+ const n = cards.length;
635
+ if (n <= 1) return cards;
636
+ const df = /* @__PURE__ */ new Map();
637
+ for (const card of cards) {
638
+ for (const tag of card.tags ?? []) {
639
+ df.set(tag, (df.get(tag) ?? 0) + 1);
640
+ }
641
+ }
642
+ const idf = /* @__PURE__ */ new Map();
643
+ for (const [tag, freq] of df) {
644
+ idf.set(tag, Math.log(n / freq));
645
+ }
646
+ const remaining = [...cards];
647
+ const emittedCount = /* @__PURE__ */ new Map();
648
+ const out = [];
649
+ const repetitionLoad = (card) => {
650
+ let load = 0;
651
+ for (const tag of card.tags ?? []) {
652
+ const seen = emittedCount.get(tag);
653
+ if (seen) load += (idf.get(tag) ?? 0) * seen;
654
+ }
655
+ return load;
656
+ };
657
+ while (remaining.length > 0) {
658
+ let bestIdx = 0;
659
+ let bestValue = -Infinity;
660
+ let bestPenalty = 1;
661
+ let bestLoad = 0;
662
+ for (let i = 0; i < remaining.length; i++) {
663
+ const card = remaining[i];
664
+ const load = repetitionLoad(card);
665
+ const penalty = load > 0 ? Math.max(floor, 1 / (1 + strength * load)) : 1;
666
+ const value = card.score * penalty;
667
+ if (value > bestValue) {
668
+ bestValue = value;
669
+ bestIdx = i;
670
+ bestPenalty = penalty;
671
+ bestLoad = load;
672
+ }
673
+ }
674
+ const [picked] = remaining.splice(bestIdx, 1);
675
+ if (Number.isFinite(picked.score) && bestPenalty < 1) {
676
+ const newScore = picked.score * bestPenalty;
677
+ out.push({
678
+ ...picked,
679
+ score: newScore,
680
+ provenance: [
681
+ ...picked.provenance,
682
+ {
683
+ strategy: STRATEGY,
684
+ strategyId: STRATEGY_ID,
685
+ strategyName: STRATEGY_NAME,
686
+ action: "penalized",
687
+ score: newScore,
688
+ reason: `repeated tags (load ${bestLoad.toFixed(2)}) \u2192 \xD7${bestPenalty.toFixed(2)}`
689
+ }
690
+ ]
691
+ });
692
+ } else {
693
+ out.push(picked);
694
+ }
695
+ for (const tag of picked.tags ?? []) {
696
+ emittedCount.set(tag, (emittedCount.get(tag) ?? 0) + 1);
697
+ }
698
+ }
699
+ return out;
700
+ }
701
+ var DIVERSITY_STRENGTH, DIVERSITY_FLOOR, STRATEGY, STRATEGY_ID, STRATEGY_NAME;
702
+ var init_diversityRerank = __esm({
703
+ "src/core/navigators/diversityRerank.ts"() {
704
+ "use strict";
705
+ DIVERSITY_STRENGTH = 0.6;
706
+ DIVERSITY_FLOOR = 0.3;
707
+ STRATEGY = "diversityRerank";
708
+ STRATEGY_ID = "DIVERSITY_RERANK";
709
+ STRATEGY_NAME = "Diversity Re-rank";
710
+ }
711
+ });
712
+
624
713
  // src/core/navigators/PipelineDebugger.ts
625
714
  var PipelineDebugger_exports = {};
626
715
  __export(PipelineDebugger_exports, {
@@ -1689,12 +1778,13 @@ __export(elo_exports, {
1689
1778
  default: () => ELONavigator
1690
1779
  });
1691
1780
  import { toCourseElo as toCourseElo2 } from "@vue-skuilder/common";
1692
- var ELONavigator;
1781
+ var ELO_RELEVANCE_SIGMA, ELONavigator;
1693
1782
  var init_elo = __esm({
1694
1783
  "src/core/navigators/generators/elo.ts"() {
1695
1784
  "use strict";
1696
1785
  init_navigators();
1697
1786
  init_logger();
1787
+ ELO_RELEVANCE_SIGMA = 300;
1698
1788
  ELONavigator = class extends ContentNavigator {
1699
1789
  /** Human-readable name for CardGenerator interface */
1700
1790
  name;
@@ -1734,8 +1824,8 @@ var init_elo = __esm({
1734
1824
  const scored = newCards.map((c) => {
1735
1825
  const cardElo = c.elo ?? 1e3;
1736
1826
  const distance = Math.abs(cardElo - userGlobalElo);
1737
- const rawScore = Math.max(0, 1 - distance / 500);
1738
- const samplingKey = rawScore > 0 ? Math.random() ** (1 / rawScore) : 0;
1827
+ const relevance = Math.exp(-((distance / ELO_RELEVANCE_SIGMA) ** 2));
1828
+ const samplingKey = relevance * (0.5 + 0.5 * Math.random());
1739
1829
  return {
1740
1830
  cardId: c.cardID,
1741
1831
  courseId: c.courseID,
@@ -1747,7 +1837,7 @@ var init_elo = __esm({
1747
1837
  strategyId: this.strategyId || "NAVIGATION_STRATEGY-ELO-default",
1748
1838
  action: "generated",
1749
1839
  score: samplingKey,
1750
- reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), raw ${rawScore.toFixed(3)}, key ${samplingKey.toFixed(3)}`
1840
+ reason: `ELO distance ${Math.round(distance)} (card: ${Math.round(cardElo)}, user: ${Math.round(userGlobalElo)}), relevance ${relevance.toFixed(3)}, key ${samplingKey.toFixed(3)}`
1751
1841
  }
1752
1842
  ]
1753
1843
  };
@@ -1815,7 +1905,7 @@ function shuffleInPlace(arr) {
1815
1905
  function pickTopByScore(cards, limit) {
1816
1906
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1817
1907
  }
1818
- 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;
1908
+ 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;
1819
1909
  var init_prescribed = __esm({
1820
1910
  "src/core/navigators/generators/prescribed.ts"() {
1821
1911
  "use strict";
@@ -1826,9 +1916,12 @@ var init_prescribed = __esm({
1826
1916
  DEFAULT_MAX_SUPPORT_PER_RUN = 3;
1827
1917
  DEFAULT_HIERARCHY_DEPTH = 2;
1828
1918
  DEFAULT_MIN_COUNT = 3;
1919
+ DEFAULT_PRACTICE_MIN_COUNT = 3;
1920
+ DEFAULT_MAX_PRACTICE_PER_RUN = 4;
1829
1921
  BASE_TARGET_SCORE = 1;
1830
1922
  BASE_SUPPORT_SCORE = 0.8;
1831
1923
  DISCOVERED_SUPPORT_SCORE = 12;
1924
+ BASE_PRACTICE_SCORE = 1;
1832
1925
  MAX_TARGET_MULTIPLIER = 8;
1833
1926
  MAX_SUPPORT_MULTIPLIER = 4;
1834
1927
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -1936,7 +2029,18 @@ var init_prescribed = __esm({
1936
2029
  courseId,
1937
2030
  emittedIds
1938
2031
  );
1939
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
2032
+ const practiceCards = this.buildPracticeCards({
2033
+ group,
2034
+ courseId,
2035
+ emittedIds,
2036
+ cardsByTag,
2037
+ hierarchyConfigs,
2038
+ userTagElo,
2039
+ userGlobalElo,
2040
+ activeIds,
2041
+ seenIds
2042
+ });
2043
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
1940
2044
  }
1941
2045
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
1942
2046
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
@@ -1964,6 +2068,7 @@ var init_prescribed = __esm({
1964
2068
  const surfacedByGroup = /* @__PURE__ */ new Map();
1965
2069
  for (const card of finalCards) {
1966
2070
  const prov = card.provenance[0];
2071
+ if (prov?.reason.includes("mode=practice")) continue;
1967
2072
  const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
1968
2073
  const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
1969
2074
  if (!groupId) continue;
@@ -2033,7 +2138,12 @@ var init_prescribed = __esm({
2033
2138
  enabled: raw.hierarchyWalk?.enabled !== false,
2034
2139
  maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
2035
2140
  },
2036
- retireOnEncounter: raw.retireOnEncounter !== false
2141
+ retireOnEncounter: raw.retireOnEncounter !== false,
2142
+ practiceTagPatterns: dedupe(
2143
+ Array.isArray(raw.practiceTagPatterns) ? raw.practiceTagPatterns.filter((v) => typeof v === "string") : []
2144
+ ),
2145
+ practiceMinCount: typeof raw.practiceMinCount === "number" ? raw.practiceMinCount : DEFAULT_PRACTICE_MIN_COUNT,
2146
+ maxPracticeCardsPerRun: typeof raw.maxPracticeCardsPerRun === "number" ? raw.maxPracticeCardsPerRun : DEFAULT_MAX_PRACTICE_PER_RUN
2037
2147
  })).filter((g) => g.targetCardIds.length > 0);
2038
2148
  return { groups };
2039
2149
  } catch {
@@ -2256,6 +2366,92 @@ var init_prescribed = __esm({
2256
2366
  }
2257
2367
  return cards;
2258
2368
  }
2369
+ /**
2370
+ * Emit drill cards for *unlocked-but-under-practiced* skills.
2371
+ *
2372
+ * For each course tag matching the group's `practiceTagPatterns` that is both
2373
+ * unlocked (all hierarchy prerequisites met — i.e. the learner has been
2374
+ * introduced to it) and under-practiced (per-tag attempt count below
2375
+ * `practiceMinCount`), this resolves cards carrying that tag and emits them
2376
+ * into the candidate pool. It exists because global-ELO retrieval
2377
+ * systematically fails to fetch the (low-ELO) drill cards for a
2378
+ * freshly-introduced skill — putting them in the pool here lets the pipeline's
2379
+ * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2380
+ * this method's job; it only guarantees presence.
2381
+ *
2382
+ * Fully data-driven: the unlock relation comes from the hierarchy config and
2383
+ * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
2384
+ */
2385
+ buildPracticeCards(args) {
2386
+ const {
2387
+ group,
2388
+ courseId,
2389
+ emittedIds,
2390
+ cardsByTag,
2391
+ hierarchyConfigs,
2392
+ userTagElo,
2393
+ userGlobalElo,
2394
+ activeIds,
2395
+ seenIds
2396
+ } = args;
2397
+ const patterns = group.practiceTagPatterns ?? [];
2398
+ if (patterns.length === 0) return [];
2399
+ const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2400
+ const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2401
+ const practiceTags = [...cardsByTag.keys()].filter(
2402
+ (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2403
+ );
2404
+ if (practiceTags.length === 0) return [];
2405
+ const practiceCardIds = this.findDiscoveredSupportCards({
2406
+ supportTags: practiceTags,
2407
+ cardsByTag,
2408
+ activeIds,
2409
+ seenIds,
2410
+ excludedIds: emittedIds,
2411
+ limit: maxPractice
2412
+ });
2413
+ if (practiceCardIds.length === 0) return [];
2414
+ logger.info(
2415
+ `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2416
+ );
2417
+ const cards = [];
2418
+ for (const cardId of practiceCardIds) {
2419
+ emittedIds.add(cardId);
2420
+ cards.push({
2421
+ cardId,
2422
+ courseId,
2423
+ score: BASE_PRACTICE_SCORE,
2424
+ provenance: [
2425
+ {
2426
+ strategy: "prescribed",
2427
+ strategyName: this.strategyName || this.name,
2428
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2429
+ action: "generated",
2430
+ score: BASE_PRACTICE_SCORE,
2431
+ reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2432
+ }
2433
+ ]
2434
+ });
2435
+ }
2436
+ return cards;
2437
+ }
2438
+ /**
2439
+ * True for a skill that was *gated and is now reached*: it has at least one
2440
+ * declared hierarchy prerequisite set, and every set is fully satisfied by the
2441
+ * learner's per-tag ELO. This deliberately EXCLUDES tags with no prerequisites
2442
+ * — an ungated tag was never "introduced" in the curricular sense, so it isn't
2443
+ * a post-intro drill target (e.g. whole-word spelling tags that share the
2444
+ * `gpc:exercise:*` prefix but have no intro gate). Those are left to normal
2445
+ * ELO retrieval. This is the precise population the retrieval gap strands:
2446
+ * just-unlocked, low-ELO skills.
2447
+ */
2448
+ isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) {
2449
+ const prereqSets = hierarchyConfigs.map((hierarchy) => hierarchy.prerequisites[tag]).filter((prereqs) => Array.isArray(prereqs) && prereqs.length > 0);
2450
+ if (prereqSets.length === 0) return false;
2451
+ return prereqSets.every(
2452
+ (prereqs) => prereqs.every((pr) => this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo))
2453
+ );
2454
+ }
2259
2455
  findSupportCardsByTags(group, tagsByCard, supportTags) {
2260
2456
  if (supportTags.length === 0) {
2261
2457
  return [];
@@ -3804,7 +4000,7 @@ function logResultCards(cards) {
3804
4000
  for (let i = 0; i < cards.length; i++) {
3805
4001
  const c = cards[i];
3806
4002
  const tags = c.tags?.slice(0, 3).join(", ") || "";
3807
- const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
4003
+ 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) => {
3808
4004
  const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
3809
4005
  return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
3810
4006
  }).join(" | ");
@@ -3835,6 +4031,7 @@ var init_Pipeline = __esm({
3835
4031
  init_logger();
3836
4032
  init_orchestration();
3837
4033
  init_PipelineDebugger();
4034
+ init_diversityRerank();
3838
4035
  VERBOSE_RESULTS = true;
3839
4036
  Pipeline = class extends ContentNavigator {
3840
4037
  generator;
@@ -4008,6 +4205,7 @@ var init_Pipeline = __esm({
4008
4205
  this._ephemeralHints = null;
4009
4206
  cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
4010
4207
  }
4208
+ cards = diversityRerank(cards);
4011
4209
  cards.sort((a, b) => b.score - a.score);
4012
4210
  const tFilter = performance.now();
4013
4211
  const result = cards.slice(0, limit);
@@ -4576,6 +4774,7 @@ var init_3 = __esm({
4576
4774
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
4577
4775
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
4578
4776
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
4777
+ "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
4579
4778
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
4580
4779
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
4581
4780
  "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
@@ -4601,9 +4800,12 @@ var init_3 = __esm({
4601
4800
  var navigators_exports = {};
4602
4801
  __export(navigators_exports, {
4603
4802
  ContentNavigator: () => ContentNavigator,
4803
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
4804
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
4604
4805
  NavigatorRole: () => NavigatorRole,
4605
4806
  NavigatorRoles: () => NavigatorRoles,
4606
4807
  Navigators: () => Navigators,
4808
+ diversityRerank: () => diversityRerank,
4607
4809
  getCardOrigin: () => getCardOrigin,
4608
4810
  getRegisteredNavigator: () => getRegisteredNavigator,
4609
4811
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
@@ -4687,6 +4889,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
4687
4889
  var init_navigators = __esm({
4688
4890
  "src/core/navigators/index.ts"() {
4689
4891
  "use strict";
4892
+ init_diversityRerank();
4690
4893
  init_PipelineDebugger();
4691
4894
  init_logger();
4692
4895
  init_();