@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.
@@ -498,12 +498,102 @@ 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, {
504
593
  buildRunReport: () => buildRunReport,
505
594
  captureRun: () => captureRun,
506
595
  clearRunHistory: () => clearRunHistory,
596
+ getActivePipeline: () => getActivePipeline,
507
597
  mountPipelineDebugger: () => mountPipelineDebugger,
508
598
  pipelineDebugAPI: () => pipelineDebugAPI,
509
599
  registerPipelineForDebug: () => registerPipelineForDebug
@@ -511,6 +601,9 @@ __export(PipelineDebugger_exports, {
511
601
  function registerPipelineForDebug(pipeline) {
512
602
  _activePipeline = pipeline;
513
603
  }
604
+ function getActivePipeline() {
605
+ return _activePipeline;
606
+ }
514
607
  function clearRunHistory() {
515
608
  runHistory.length = 0;
516
609
  }
@@ -1693,7 +1786,7 @@ function shuffleInPlace(arr) {
1693
1786
  function pickTopByScore(cards, limit) {
1694
1787
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1695
1788
  }
1696
- 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;
1789
+ 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;
1697
1790
  var init_prescribed = __esm({
1698
1791
  "src/core/navigators/generators/prescribed.ts"() {
1699
1792
  "use strict";
@@ -1704,9 +1797,12 @@ var init_prescribed = __esm({
1704
1797
  DEFAULT_MAX_SUPPORT_PER_RUN = 3;
1705
1798
  DEFAULT_HIERARCHY_DEPTH = 2;
1706
1799
  DEFAULT_MIN_COUNT = 3;
1800
+ DEFAULT_PRACTICE_MIN_COUNT = 3;
1801
+ DEFAULT_MAX_PRACTICE_PER_RUN = 4;
1707
1802
  BASE_TARGET_SCORE = 1;
1708
1803
  BASE_SUPPORT_SCORE = 0.8;
1709
1804
  DISCOVERED_SUPPORT_SCORE = 12;
1805
+ BASE_PRACTICE_SCORE = 1;
1710
1806
  MAX_TARGET_MULTIPLIER = 8;
1711
1807
  MAX_SUPPORT_MULTIPLIER = 4;
1712
1808
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -1814,7 +1910,18 @@ var init_prescribed = __esm({
1814
1910
  courseId,
1815
1911
  emittedIds
1816
1912
  );
1817
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
1913
+ const practiceCards = this.buildPracticeCards({
1914
+ group,
1915
+ courseId,
1916
+ emittedIds,
1917
+ cardsByTag,
1918
+ hierarchyConfigs,
1919
+ userTagElo,
1920
+ userGlobalElo,
1921
+ activeIds,
1922
+ seenIds
1923
+ });
1924
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
1818
1925
  }
1819
1926
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
1820
1927
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
@@ -1842,6 +1949,7 @@ var init_prescribed = __esm({
1842
1949
  const surfacedByGroup = /* @__PURE__ */ new Map();
1843
1950
  for (const card of finalCards) {
1844
1951
  const prov = card.provenance[0];
1952
+ if (prov?.reason.includes("mode=practice")) continue;
1845
1953
  const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
1846
1954
  const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
1847
1955
  if (!groupId) continue;
@@ -1911,7 +2019,12 @@ var init_prescribed = __esm({
1911
2019
  enabled: raw.hierarchyWalk?.enabled !== false,
1912
2020
  maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
1913
2021
  },
1914
- retireOnEncounter: raw.retireOnEncounter !== false
2022
+ retireOnEncounter: raw.retireOnEncounter !== false,
2023
+ practiceTagPatterns: dedupe(
2024
+ Array.isArray(raw.practiceTagPatterns) ? raw.practiceTagPatterns.filter((v) => typeof v === "string") : []
2025
+ ),
2026
+ practiceMinCount: typeof raw.practiceMinCount === "number" ? raw.practiceMinCount : DEFAULT_PRACTICE_MIN_COUNT,
2027
+ maxPracticeCardsPerRun: typeof raw.maxPracticeCardsPerRun === "number" ? raw.maxPracticeCardsPerRun : DEFAULT_MAX_PRACTICE_PER_RUN
1915
2028
  })).filter((g) => g.targetCardIds.length > 0);
1916
2029
  return { groups };
1917
2030
  } catch {
@@ -2134,6 +2247,92 @@ var init_prescribed = __esm({
2134
2247
  }
2135
2248
  return cards;
2136
2249
  }
2250
+ /**
2251
+ * Emit drill cards for *unlocked-but-under-practiced* skills.
2252
+ *
2253
+ * For each course tag matching the group's `practiceTagPatterns` that is both
2254
+ * unlocked (all hierarchy prerequisites met — i.e. the learner has been
2255
+ * introduced to it) and under-practiced (per-tag attempt count below
2256
+ * `practiceMinCount`), this resolves cards carrying that tag and emits them
2257
+ * into the candidate pool. It exists because global-ELO retrieval
2258
+ * systematically fails to fetch the (low-ELO) drill cards for a
2259
+ * freshly-introduced skill — putting them in the pool here lets the pipeline's
2260
+ * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2261
+ * this method's job; it only guarantees presence.
2262
+ *
2263
+ * Fully data-driven: the unlock relation comes from the hierarchy config and
2264
+ * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
2265
+ */
2266
+ buildPracticeCards(args) {
2267
+ const {
2268
+ group,
2269
+ courseId,
2270
+ emittedIds,
2271
+ cardsByTag,
2272
+ hierarchyConfigs,
2273
+ userTagElo,
2274
+ userGlobalElo,
2275
+ activeIds,
2276
+ seenIds
2277
+ } = args;
2278
+ const patterns = group.practiceTagPatterns ?? [];
2279
+ if (patterns.length === 0) return [];
2280
+ const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2281
+ const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2282
+ const practiceTags = [...cardsByTag.keys()].filter(
2283
+ (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2284
+ );
2285
+ if (practiceTags.length === 0) return [];
2286
+ const practiceCardIds = this.findDiscoveredSupportCards({
2287
+ supportTags: practiceTags,
2288
+ cardsByTag,
2289
+ activeIds,
2290
+ seenIds,
2291
+ excludedIds: emittedIds,
2292
+ limit: maxPractice
2293
+ });
2294
+ if (practiceCardIds.length === 0) return [];
2295
+ logger.info(
2296
+ `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2297
+ );
2298
+ const cards = [];
2299
+ for (const cardId of practiceCardIds) {
2300
+ emittedIds.add(cardId);
2301
+ cards.push({
2302
+ cardId,
2303
+ courseId,
2304
+ score: BASE_PRACTICE_SCORE,
2305
+ provenance: [
2306
+ {
2307
+ strategy: "prescribed",
2308
+ strategyName: this.strategyName || this.name,
2309
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2310
+ action: "generated",
2311
+ score: BASE_PRACTICE_SCORE,
2312
+ reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2313
+ }
2314
+ ]
2315
+ });
2316
+ }
2317
+ return cards;
2318
+ }
2319
+ /**
2320
+ * True for a skill that was *gated and is now reached*: it has at least one
2321
+ * declared hierarchy prerequisite set, and every set is fully satisfied by the
2322
+ * learner's per-tag ELO. This deliberately EXCLUDES tags with no prerequisites
2323
+ * — an ungated tag was never "introduced" in the curricular sense, so it isn't
2324
+ * a post-intro drill target (e.g. whole-word spelling tags that share the
2325
+ * `gpc:exercise:*` prefix but have no intro gate). Those are left to normal
2326
+ * ELO retrieval. This is the precise population the retrieval gap strands:
2327
+ * just-unlocked, low-ELO skills.
2328
+ */
2329
+ isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) {
2330
+ const prereqSets = hierarchyConfigs.map((hierarchy) => hierarchy.prerequisites[tag]).filter((prereqs) => Array.isArray(prereqs) && prereqs.length > 0);
2331
+ if (prereqSets.length === 0) return false;
2332
+ return prereqSets.every(
2333
+ (prereqs) => prereqs.every((pr) => this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo))
2334
+ );
2335
+ }
2137
2336
  findSupportCardsByTags(group, tagsByCard, supportTags) {
2138
2337
  if (supportTags.length === 0) {
2139
2338
  return [];
@@ -3682,7 +3881,7 @@ function logResultCards(cards) {
3682
3881
  for (let i = 0; i < cards.length; i++) {
3683
3882
  const c = cards[i];
3684
3883
  const tags = c.tags?.slice(0, 3).join(", ") || "";
3685
- const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
3884
+ 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) => {
3686
3885
  const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
3687
3886
  return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
3688
3887
  }).join(" | ");
@@ -3713,6 +3912,7 @@ var init_Pipeline = __esm({
3713
3912
  init_logger();
3714
3913
  init_orchestration();
3715
3914
  init_PipelineDebugger();
3915
+ init_diversityRerank();
3716
3916
  VERBOSE_RESULTS = true;
3717
3917
  Pipeline = class extends ContentNavigator {
3718
3918
  generator;
@@ -3886,6 +4086,7 @@ var init_Pipeline = __esm({
3886
4086
  this._ephemeralHints = null;
3887
4087
  cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
3888
4088
  }
4089
+ cards = diversityRerank(cards);
3889
4090
  cards.sort((a, b) => b.score - a.score);
3890
4091
  const tFilter = performance.now();
3891
4092
  const result = cards.slice(0, limit);
@@ -4189,6 +4390,68 @@ var init_Pipeline = __esm({
4189
4390
  // ---------------------------------------------------------------------------
4190
4391
  // Card-space diagnostic
4191
4392
  // ---------------------------------------------------------------------------
4393
+ /**
4394
+ * Commit-free forecast: score the user's full card space through the filter
4395
+ * chain and return the cards that are currently *reachable* (score >=
4396
+ * threshold), optionally nudged by caller-supplied hints and/or restricted
4397
+ * to cards the user hasn't seen yet.
4398
+ *
4399
+ * This is a GENERIC primitive — it returns scored, tag-hydrated cards and
4400
+ * stops there. It has no knowledge of any particular tag convention; callers
4401
+ * decide what the surviving cards mean (e.g. filter to their own "intro"
4402
+ * tag family). Nothing is written and no session is started.
4403
+ *
4404
+ * The optional `hints` are the "out-of-band kick": they run through the same
4405
+ * {@link applyHints} path a live replan uses, so the two semantics carry over —
4406
+ * - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
4407
+ * stays out), and
4408
+ * - `requireTags`/`requireCards` inject from the full pre-filter pool,
4409
+ * *bypassing* gating (use when you want a card regardless of reachability).
4410
+ * Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
4411
+ * user has already seen — pass `unseenOnly: false` if that matters.
4412
+ *
4413
+ * Cost note: like {@link diagnoseCardSpace}, this scans every card through the
4414
+ * filters, so it's heavier than a normal replan. Intended for one-shot
4415
+ * out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
4416
+ *
4417
+ * @param opts.hints Optional ephemeral hints to apply after the filter chain.
4418
+ * @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
4419
+ * @param opts.threshold Min score to count as reachable (default 0.10).
4420
+ * @param opts.limit Optional cap on results (already sorted desc).
4421
+ */
4422
+ async forecast(opts) {
4423
+ const threshold = opts?.threshold ?? 0.1;
4424
+ const unseenOnly = opts?.unseenOnly ?? true;
4425
+ const courseId = this.course.getCourseID();
4426
+ const allCardIds = await this.course.getAllCardIds();
4427
+ let cards = allCardIds.map((cardId) => ({
4428
+ cardId,
4429
+ courseId,
4430
+ score: 1,
4431
+ provenance: []
4432
+ }));
4433
+ cards = await this.hydrateTags(cards);
4434
+ const fullPool = cards.slice();
4435
+ const context = await this.buildContext();
4436
+ for (const filter of this.filters) {
4437
+ cards = await filter.transform(cards, context);
4438
+ }
4439
+ if (opts?.hints) {
4440
+ cards = this.applyHints(cards, opts.hints, fullPool);
4441
+ }
4442
+ cards = cards.filter((c) => c.score >= threshold);
4443
+ if (unseenOnly) {
4444
+ let encountered;
4445
+ try {
4446
+ encountered = new Set(await this.user.getSeenCards(courseId));
4447
+ } catch {
4448
+ encountered = /* @__PURE__ */ new Set();
4449
+ }
4450
+ cards = cards.filter((c) => !encountered.has(c.cardId));
4451
+ }
4452
+ cards.sort((a, b) => b.score - a.score);
4453
+ return opts?.limit ? cards.slice(0, opts.limit) : cards;
4454
+ }
4192
4455
  /**
4193
4456
  * Scan every card in the course through the filter chain and report
4194
4457
  * how many are "well indicated" (score >= threshold) for the current user.
@@ -4454,6 +4717,7 @@ var init_3 = __esm({
4454
4717
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
4455
4718
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
4456
4719
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
4720
+ "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
4457
4721
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
4458
4722
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
4459
4723
  "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
@@ -4479,9 +4743,13 @@ var init_3 = __esm({
4479
4743
  var navigators_exports = {};
4480
4744
  __export(navigators_exports, {
4481
4745
  ContentNavigator: () => ContentNavigator,
4746
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
4747
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
4482
4748
  NavigatorRole: () => NavigatorRole,
4483
4749
  NavigatorRoles: () => NavigatorRoles,
4484
4750
  Navigators: () => Navigators,
4751
+ diversityRerank: () => diversityRerank,
4752
+ getActivePipeline: () => getActivePipeline,
4485
4753
  getCardOrigin: () => getCardOrigin,
4486
4754
  getRegisteredNavigator: () => getRegisteredNavigator,
4487
4755
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
@@ -4565,6 +4833,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
4565
4833
  var init_navigators = __esm({
4566
4834
  "src/core/navigators/index.ts"() {
4567
4835
  "use strict";
4836
+ init_diversityRerank();
4568
4837
  init_PipelineDebugger();
4569
4838
  init_logger();
4570
4839
  init_();