@vue-skuilder/db 0.2.5 → 0.2.8

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.
@@ -696,12 +696,102 @@ var init_courseLookupDB = __esm({
696
696
  }
697
697
  });
698
698
 
699
+ // src/core/navigators/diversityRerank.ts
700
+ var diversityRerank_exports = {};
701
+ __export(diversityRerank_exports, {
702
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
703
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
704
+ diversityRerank: () => diversityRerank
705
+ });
706
+ function diversityRerank(cards, opts = {}) {
707
+ const strength = opts.strength ?? DIVERSITY_STRENGTH;
708
+ const floor = opts.floor ?? DIVERSITY_FLOOR;
709
+ const n = cards.length;
710
+ if (n <= 1) return cards;
711
+ const df = /* @__PURE__ */ new Map();
712
+ for (const card of cards) {
713
+ for (const tag of card.tags ?? []) {
714
+ df.set(tag, (df.get(tag) ?? 0) + 1);
715
+ }
716
+ }
717
+ const idf = /* @__PURE__ */ new Map();
718
+ for (const [tag, freq] of df) {
719
+ idf.set(tag, Math.log(n / freq));
720
+ }
721
+ const remaining = [...cards];
722
+ const emittedCount = /* @__PURE__ */ new Map();
723
+ const out = [];
724
+ const repetitionLoad = (card) => {
725
+ let load = 0;
726
+ for (const tag of card.tags ?? []) {
727
+ const seen = emittedCount.get(tag);
728
+ if (seen) load += (idf.get(tag) ?? 0) * seen;
729
+ }
730
+ return load;
731
+ };
732
+ while (remaining.length > 0) {
733
+ let bestIdx = 0;
734
+ let bestValue = -Infinity;
735
+ let bestPenalty = 1;
736
+ let bestLoad = 0;
737
+ for (let i = 0; i < remaining.length; i++) {
738
+ const card = remaining[i];
739
+ const load = repetitionLoad(card);
740
+ const penalty = load > 0 ? Math.max(floor, 1 / (1 + strength * load)) : 1;
741
+ const value = card.score * penalty;
742
+ if (value > bestValue) {
743
+ bestValue = value;
744
+ bestIdx = i;
745
+ bestPenalty = penalty;
746
+ bestLoad = load;
747
+ }
748
+ }
749
+ const [picked] = remaining.splice(bestIdx, 1);
750
+ if (Number.isFinite(picked.score) && bestPenalty < 1) {
751
+ const newScore = picked.score * bestPenalty;
752
+ out.push({
753
+ ...picked,
754
+ score: newScore,
755
+ provenance: [
756
+ ...picked.provenance,
757
+ {
758
+ strategy: STRATEGY,
759
+ strategyId: STRATEGY_ID,
760
+ strategyName: STRATEGY_NAME,
761
+ action: "penalized",
762
+ score: newScore,
763
+ reason: `repeated tags (load ${bestLoad.toFixed(2)}) \u2192 \xD7${bestPenalty.toFixed(2)}`
764
+ }
765
+ ]
766
+ });
767
+ } else {
768
+ out.push(picked);
769
+ }
770
+ for (const tag of picked.tags ?? []) {
771
+ emittedCount.set(tag, (emittedCount.get(tag) ?? 0) + 1);
772
+ }
773
+ }
774
+ return out;
775
+ }
776
+ var DIVERSITY_STRENGTH, DIVERSITY_FLOOR, STRATEGY, STRATEGY_ID, STRATEGY_NAME;
777
+ var init_diversityRerank = __esm({
778
+ "src/core/navigators/diversityRerank.ts"() {
779
+ "use strict";
780
+ DIVERSITY_STRENGTH = 0.6;
781
+ DIVERSITY_FLOOR = 0.3;
782
+ STRATEGY = "diversityRerank";
783
+ STRATEGY_ID = "DIVERSITY_RERANK";
784
+ STRATEGY_NAME = "Diversity Re-rank";
785
+ }
786
+ });
787
+
699
788
  // src/core/navigators/PipelineDebugger.ts
700
789
  var PipelineDebugger_exports = {};
701
790
  __export(PipelineDebugger_exports, {
702
791
  buildRunReport: () => buildRunReport,
703
792
  captureRun: () => captureRun,
704
793
  clearRunHistory: () => clearRunHistory,
794
+ getActivePipeline: () => getActivePipeline,
705
795
  mountPipelineDebugger: () => mountPipelineDebugger,
706
796
  pipelineDebugAPI: () => pipelineDebugAPI,
707
797
  registerPipelineForDebug: () => registerPipelineForDebug
@@ -709,6 +799,9 @@ __export(PipelineDebugger_exports, {
709
799
  function registerPipelineForDebug(pipeline) {
710
800
  _activePipeline = pipeline;
711
801
  }
802
+ function getActivePipeline() {
803
+ return _activePipeline;
804
+ }
712
805
  function clearRunHistory() {
713
806
  runHistory.length = 0;
714
807
  }
@@ -1891,7 +1984,7 @@ function shuffleInPlace(arr) {
1891
1984
  function pickTopByScore(cards, limit) {
1892
1985
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1893
1986
  }
1894
- 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;
1987
+ 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;
1895
1988
  var init_prescribed = __esm({
1896
1989
  "src/core/navigators/generators/prescribed.ts"() {
1897
1990
  "use strict";
@@ -1902,9 +1995,12 @@ var init_prescribed = __esm({
1902
1995
  DEFAULT_MAX_SUPPORT_PER_RUN = 3;
1903
1996
  DEFAULT_HIERARCHY_DEPTH = 2;
1904
1997
  DEFAULT_MIN_COUNT = 3;
1998
+ DEFAULT_PRACTICE_MIN_COUNT = 3;
1999
+ DEFAULT_MAX_PRACTICE_PER_RUN = 4;
1905
2000
  BASE_TARGET_SCORE = 1;
1906
2001
  BASE_SUPPORT_SCORE = 0.8;
1907
2002
  DISCOVERED_SUPPORT_SCORE = 12;
2003
+ BASE_PRACTICE_SCORE = 1;
1908
2004
  MAX_TARGET_MULTIPLIER = 8;
1909
2005
  MAX_SUPPORT_MULTIPLIER = 4;
1910
2006
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -2012,7 +2108,18 @@ var init_prescribed = __esm({
2012
2108
  courseId,
2013
2109
  emittedIds
2014
2110
  );
2015
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
2111
+ const practiceCards = this.buildPracticeCards({
2112
+ group,
2113
+ courseId,
2114
+ emittedIds,
2115
+ cardsByTag,
2116
+ hierarchyConfigs,
2117
+ userTagElo,
2118
+ userGlobalElo,
2119
+ activeIds,
2120
+ seenIds
2121
+ });
2122
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
2016
2123
  }
2017
2124
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
2018
2125
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
@@ -2040,6 +2147,7 @@ var init_prescribed = __esm({
2040
2147
  const surfacedByGroup = /* @__PURE__ */ new Map();
2041
2148
  for (const card of finalCards) {
2042
2149
  const prov = card.provenance[0];
2150
+ if (prov?.reason.includes("mode=practice")) continue;
2043
2151
  const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
2044
2152
  const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
2045
2153
  if (!groupId) continue;
@@ -2109,7 +2217,12 @@ var init_prescribed = __esm({
2109
2217
  enabled: raw.hierarchyWalk?.enabled !== false,
2110
2218
  maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
2111
2219
  },
2112
- retireOnEncounter: raw.retireOnEncounter !== false
2220
+ retireOnEncounter: raw.retireOnEncounter !== false,
2221
+ practiceTagPatterns: dedupe(
2222
+ Array.isArray(raw.practiceTagPatterns) ? raw.practiceTagPatterns.filter((v) => typeof v === "string") : []
2223
+ ),
2224
+ practiceMinCount: typeof raw.practiceMinCount === "number" ? raw.practiceMinCount : DEFAULT_PRACTICE_MIN_COUNT,
2225
+ maxPracticeCardsPerRun: typeof raw.maxPracticeCardsPerRun === "number" ? raw.maxPracticeCardsPerRun : DEFAULT_MAX_PRACTICE_PER_RUN
2113
2226
  })).filter((g) => g.targetCardIds.length > 0);
2114
2227
  return { groups };
2115
2228
  } catch {
@@ -2332,6 +2445,92 @@ var init_prescribed = __esm({
2332
2445
  }
2333
2446
  return cards;
2334
2447
  }
2448
+ /**
2449
+ * Emit drill cards for *unlocked-but-under-practiced* skills.
2450
+ *
2451
+ * For each course tag matching the group's `practiceTagPatterns` that is both
2452
+ * unlocked (all hierarchy prerequisites met — i.e. the learner has been
2453
+ * introduced to it) and under-practiced (per-tag attempt count below
2454
+ * `practiceMinCount`), this resolves cards carrying that tag and emits them
2455
+ * into the candidate pool. It exists because global-ELO retrieval
2456
+ * systematically fails to fetch the (low-ELO) drill cards for a
2457
+ * freshly-introduced skill — putting them in the pool here lets the pipeline's
2458
+ * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2459
+ * this method's job; it only guarantees presence.
2460
+ *
2461
+ * Fully data-driven: the unlock relation comes from the hierarchy config and
2462
+ * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
2463
+ */
2464
+ buildPracticeCards(args) {
2465
+ const {
2466
+ group,
2467
+ courseId,
2468
+ emittedIds,
2469
+ cardsByTag,
2470
+ hierarchyConfigs,
2471
+ userTagElo,
2472
+ userGlobalElo,
2473
+ activeIds,
2474
+ seenIds
2475
+ } = args;
2476
+ const patterns = group.practiceTagPatterns ?? [];
2477
+ if (patterns.length === 0) return [];
2478
+ const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2479
+ const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2480
+ const practiceTags = [...cardsByTag.keys()].filter(
2481
+ (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2482
+ );
2483
+ if (practiceTags.length === 0) return [];
2484
+ const practiceCardIds = this.findDiscoveredSupportCards({
2485
+ supportTags: practiceTags,
2486
+ cardsByTag,
2487
+ activeIds,
2488
+ seenIds,
2489
+ excludedIds: emittedIds,
2490
+ limit: maxPractice
2491
+ });
2492
+ if (practiceCardIds.length === 0) return [];
2493
+ logger.info(
2494
+ `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2495
+ );
2496
+ const cards = [];
2497
+ for (const cardId of practiceCardIds) {
2498
+ emittedIds.add(cardId);
2499
+ cards.push({
2500
+ cardId,
2501
+ courseId,
2502
+ score: BASE_PRACTICE_SCORE,
2503
+ provenance: [
2504
+ {
2505
+ strategy: "prescribed",
2506
+ strategyName: this.strategyName || this.name,
2507
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2508
+ action: "generated",
2509
+ score: BASE_PRACTICE_SCORE,
2510
+ reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2511
+ }
2512
+ ]
2513
+ });
2514
+ }
2515
+ return cards;
2516
+ }
2517
+ /**
2518
+ * True for a skill that was *gated and is now reached*: it has at least one
2519
+ * declared hierarchy prerequisite set, and every set is fully satisfied by the
2520
+ * learner's per-tag ELO. This deliberately EXCLUDES tags with no prerequisites
2521
+ * — an ungated tag was never "introduced" in the curricular sense, so it isn't
2522
+ * a post-intro drill target (e.g. whole-word spelling tags that share the
2523
+ * `gpc:exercise:*` prefix but have no intro gate). Those are left to normal
2524
+ * ELO retrieval. This is the precise population the retrieval gap strands:
2525
+ * just-unlocked, low-ELO skills.
2526
+ */
2527
+ isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) {
2528
+ const prereqSets = hierarchyConfigs.map((hierarchy) => hierarchy.prerequisites[tag]).filter((prereqs) => Array.isArray(prereqs) && prereqs.length > 0);
2529
+ if (prereqSets.length === 0) return false;
2530
+ return prereqSets.every(
2531
+ (prereqs) => prereqs.every((pr) => this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo))
2532
+ );
2533
+ }
2335
2534
  findSupportCardsByTags(group, tagsByCard, supportTags) {
2336
2535
  if (supportTags.length === 0) {
2337
2536
  return [];
@@ -4126,7 +4325,7 @@ function logResultCards(cards) {
4126
4325
  for (let i = 0; i < cards.length; i++) {
4127
4326
  const c = cards[i];
4128
4327
  const tags = c.tags?.slice(0, 3).join(", ") || "";
4129
- const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
4328
+ 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) => {
4130
4329
  const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
4131
4330
  return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
4132
4331
  }).join(" | ");
@@ -4157,6 +4356,7 @@ var init_Pipeline = __esm({
4157
4356
  init_logger();
4158
4357
  init_orchestration();
4159
4358
  init_PipelineDebugger();
4359
+ init_diversityRerank();
4160
4360
  VERBOSE_RESULTS = true;
4161
4361
  Pipeline = class extends ContentNavigator {
4162
4362
  generator;
@@ -4330,6 +4530,7 @@ var init_Pipeline = __esm({
4330
4530
  this._ephemeralHints = null;
4331
4531
  cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
4332
4532
  }
4533
+ cards = diversityRerank(cards);
4333
4534
  cards.sort((a, b) => b.score - a.score);
4334
4535
  const tFilter = performance.now();
4335
4536
  const result = cards.slice(0, limit);
@@ -4633,6 +4834,68 @@ var init_Pipeline = __esm({
4633
4834
  // ---------------------------------------------------------------------------
4634
4835
  // Card-space diagnostic
4635
4836
  // ---------------------------------------------------------------------------
4837
+ /**
4838
+ * Commit-free forecast: score the user's full card space through the filter
4839
+ * chain and return the cards that are currently *reachable* (score >=
4840
+ * threshold), optionally nudged by caller-supplied hints and/or restricted
4841
+ * to cards the user hasn't seen yet.
4842
+ *
4843
+ * This is a GENERIC primitive — it returns scored, tag-hydrated cards and
4844
+ * stops there. It has no knowledge of any particular tag convention; callers
4845
+ * decide what the surviving cards mean (e.g. filter to their own "intro"
4846
+ * tag family). Nothing is written and no session is started.
4847
+ *
4848
+ * The optional `hints` are the "out-of-band kick": they run through the same
4849
+ * {@link applyHints} path a live replan uses, so the two semantics carry over —
4850
+ * - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
4851
+ * stays out), and
4852
+ * - `requireTags`/`requireCards` inject from the full pre-filter pool,
4853
+ * *bypassing* gating (use when you want a card regardless of reachability).
4854
+ * Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
4855
+ * user has already seen — pass `unseenOnly: false` if that matters.
4856
+ *
4857
+ * Cost note: like {@link diagnoseCardSpace}, this scans every card through the
4858
+ * filters, so it's heavier than a normal replan. Intended for one-shot
4859
+ * out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
4860
+ *
4861
+ * @param opts.hints Optional ephemeral hints to apply after the filter chain.
4862
+ * @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
4863
+ * @param opts.threshold Min score to count as reachable (default 0.10).
4864
+ * @param opts.limit Optional cap on results (already sorted desc).
4865
+ */
4866
+ async forecast(opts) {
4867
+ const threshold = opts?.threshold ?? 0.1;
4868
+ const unseenOnly = opts?.unseenOnly ?? true;
4869
+ const courseId = this.course.getCourseID();
4870
+ const allCardIds = await this.course.getAllCardIds();
4871
+ let cards = allCardIds.map((cardId) => ({
4872
+ cardId,
4873
+ courseId,
4874
+ score: 1,
4875
+ provenance: []
4876
+ }));
4877
+ cards = await this.hydrateTags(cards);
4878
+ const fullPool = cards.slice();
4879
+ const context = await this.buildContext();
4880
+ for (const filter of this.filters) {
4881
+ cards = await filter.transform(cards, context);
4882
+ }
4883
+ if (opts?.hints) {
4884
+ cards = this.applyHints(cards, opts.hints, fullPool);
4885
+ }
4886
+ cards = cards.filter((c) => c.score >= threshold);
4887
+ if (unseenOnly) {
4888
+ let encountered;
4889
+ try {
4890
+ encountered = new Set(await this.user.getSeenCards(courseId));
4891
+ } catch {
4892
+ encountered = /* @__PURE__ */ new Set();
4893
+ }
4894
+ cards = cards.filter((c) => !encountered.has(c.cardId));
4895
+ }
4896
+ cards.sort((a, b) => b.score - a.score);
4897
+ return opts?.limit ? cards.slice(0, opts.limit) : cards;
4898
+ }
4636
4899
  /**
4637
4900
  * Scan every card in the course through the filter chain and report
4638
4901
  * how many are "well indicated" (score >= threshold) for the current user.
@@ -4898,6 +5161,7 @@ var init_3 = __esm({
4898
5161
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
4899
5162
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
4900
5163
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
5164
+ "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
4901
5165
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
4902
5166
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
4903
5167
  "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
@@ -4923,9 +5187,13 @@ var init_3 = __esm({
4923
5187
  var navigators_exports = {};
4924
5188
  __export(navigators_exports, {
4925
5189
  ContentNavigator: () => ContentNavigator,
5190
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
5191
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
4926
5192
  NavigatorRole: () => NavigatorRole,
4927
5193
  NavigatorRoles: () => NavigatorRoles,
4928
5194
  Navigators: () => Navigators,
5195
+ diversityRerank: () => diversityRerank,
5196
+ getActivePipeline: () => getActivePipeline,
4929
5197
  getCardOrigin: () => getCardOrigin,
4930
5198
  getRegisteredNavigator: () => getRegisteredNavigator,
4931
5199
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
@@ -5009,6 +5277,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
5009
5277
  var init_navigators = __esm({
5010
5278
  "src/core/navigators/index.ts"() {
5011
5279
  "use strict";
5280
+ init_diversityRerank();
5012
5281
  init_PipelineDebugger();
5013
5282
  init_logger();
5014
5283
  init_();
@@ -8032,6 +8301,8 @@ var init_core = __esm({
8032
8301
  init_core();
8033
8302
  export {
8034
8303
  ContentNavigator,
8304
+ DIVERSITY_FLOOR,
8305
+ DIVERSITY_STRENGTH,
8035
8306
  DocType,
8036
8307
  DocTypePrefixes,
8037
8308
  GuestUsername,
@@ -8048,7 +8319,9 @@ export {
8048
8319
  computeSpread,
8049
8320
  computeStrategyGradient,
8050
8321
  createOrchestrationContext,
8322
+ diversityRerank,
8051
8323
  docIsDeleted,
8324
+ getActivePipeline,
8052
8325
  getCardHistoryID,
8053
8326
  getCardOrigin,
8054
8327
  getDefaultLearnableWeight,