@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.
@@ -621,12 +621,102 @@ 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, {
627
716
  buildRunReport: () => buildRunReport,
628
717
  captureRun: () => captureRun,
629
718
  clearRunHistory: () => clearRunHistory,
719
+ getActivePipeline: () => getActivePipeline,
630
720
  mountPipelineDebugger: () => mountPipelineDebugger,
631
721
  pipelineDebugAPI: () => pipelineDebugAPI,
632
722
  registerPipelineForDebug: () => registerPipelineForDebug
@@ -634,6 +724,9 @@ __export(PipelineDebugger_exports, {
634
724
  function registerPipelineForDebug(pipeline) {
635
725
  _activePipeline = pipeline;
636
726
  }
727
+ function getActivePipeline() {
728
+ return _activePipeline;
729
+ }
637
730
  function clearRunHistory() {
638
731
  runHistory.length = 0;
639
732
  }
@@ -1816,7 +1909,7 @@ function shuffleInPlace(arr) {
1816
1909
  function pickTopByScore(cards, limit) {
1817
1910
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1818
1911
  }
1819
- 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;
1912
+ 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;
1820
1913
  var init_prescribed = __esm({
1821
1914
  "src/core/navigators/generators/prescribed.ts"() {
1822
1915
  "use strict";
@@ -1827,9 +1920,12 @@ var init_prescribed = __esm({
1827
1920
  DEFAULT_MAX_SUPPORT_PER_RUN = 3;
1828
1921
  DEFAULT_HIERARCHY_DEPTH = 2;
1829
1922
  DEFAULT_MIN_COUNT = 3;
1923
+ DEFAULT_PRACTICE_MIN_COUNT = 3;
1924
+ DEFAULT_MAX_PRACTICE_PER_RUN = 4;
1830
1925
  BASE_TARGET_SCORE = 1;
1831
1926
  BASE_SUPPORT_SCORE = 0.8;
1832
1927
  DISCOVERED_SUPPORT_SCORE = 12;
1928
+ BASE_PRACTICE_SCORE = 1;
1833
1929
  MAX_TARGET_MULTIPLIER = 8;
1834
1930
  MAX_SUPPORT_MULTIPLIER = 4;
1835
1931
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -1937,7 +2033,18 @@ var init_prescribed = __esm({
1937
2033
  courseId,
1938
2034
  emittedIds
1939
2035
  );
1940
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
2036
+ const practiceCards = this.buildPracticeCards({
2037
+ group,
2038
+ courseId,
2039
+ emittedIds,
2040
+ cardsByTag,
2041
+ hierarchyConfigs,
2042
+ userTagElo,
2043
+ userGlobalElo,
2044
+ activeIds,
2045
+ seenIds
2046
+ });
2047
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
1941
2048
  }
1942
2049
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
1943
2050
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
@@ -1965,6 +2072,7 @@ var init_prescribed = __esm({
1965
2072
  const surfacedByGroup = /* @__PURE__ */ new Map();
1966
2073
  for (const card of finalCards) {
1967
2074
  const prov = card.provenance[0];
2075
+ if (prov?.reason.includes("mode=practice")) continue;
1968
2076
  const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
1969
2077
  const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
1970
2078
  if (!groupId) continue;
@@ -2034,7 +2142,12 @@ var init_prescribed = __esm({
2034
2142
  enabled: raw.hierarchyWalk?.enabled !== false,
2035
2143
  maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
2036
2144
  },
2037
- retireOnEncounter: raw.retireOnEncounter !== false
2145
+ retireOnEncounter: raw.retireOnEncounter !== false,
2146
+ practiceTagPatterns: dedupe(
2147
+ Array.isArray(raw.practiceTagPatterns) ? raw.practiceTagPatterns.filter((v) => typeof v === "string") : []
2148
+ ),
2149
+ practiceMinCount: typeof raw.practiceMinCount === "number" ? raw.practiceMinCount : DEFAULT_PRACTICE_MIN_COUNT,
2150
+ maxPracticeCardsPerRun: typeof raw.maxPracticeCardsPerRun === "number" ? raw.maxPracticeCardsPerRun : DEFAULT_MAX_PRACTICE_PER_RUN
2038
2151
  })).filter((g) => g.targetCardIds.length > 0);
2039
2152
  return { groups };
2040
2153
  } catch {
@@ -2257,6 +2370,92 @@ var init_prescribed = __esm({
2257
2370
  }
2258
2371
  return cards;
2259
2372
  }
2373
+ /**
2374
+ * Emit drill cards for *unlocked-but-under-practiced* skills.
2375
+ *
2376
+ * For each course tag matching the group's `practiceTagPatterns` that is both
2377
+ * unlocked (all hierarchy prerequisites met — i.e. the learner has been
2378
+ * introduced to it) and under-practiced (per-tag attempt count below
2379
+ * `practiceMinCount`), this resolves cards carrying that tag and emits them
2380
+ * into the candidate pool. It exists because global-ELO retrieval
2381
+ * systematically fails to fetch the (low-ELO) drill cards for a
2382
+ * freshly-introduced skill — putting them in the pool here lets the pipeline's
2383
+ * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2384
+ * this method's job; it only guarantees presence.
2385
+ *
2386
+ * Fully data-driven: the unlock relation comes from the hierarchy config and
2387
+ * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
2388
+ */
2389
+ buildPracticeCards(args) {
2390
+ const {
2391
+ group,
2392
+ courseId,
2393
+ emittedIds,
2394
+ cardsByTag,
2395
+ hierarchyConfigs,
2396
+ userTagElo,
2397
+ userGlobalElo,
2398
+ activeIds,
2399
+ seenIds
2400
+ } = args;
2401
+ const patterns = group.practiceTagPatterns ?? [];
2402
+ if (patterns.length === 0) return [];
2403
+ const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2404
+ const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2405
+ const practiceTags = [...cardsByTag.keys()].filter(
2406
+ (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2407
+ );
2408
+ if (practiceTags.length === 0) return [];
2409
+ const practiceCardIds = this.findDiscoveredSupportCards({
2410
+ supportTags: practiceTags,
2411
+ cardsByTag,
2412
+ activeIds,
2413
+ seenIds,
2414
+ excludedIds: emittedIds,
2415
+ limit: maxPractice
2416
+ });
2417
+ if (practiceCardIds.length === 0) return [];
2418
+ logger.info(
2419
+ `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2420
+ );
2421
+ const cards = [];
2422
+ for (const cardId of practiceCardIds) {
2423
+ emittedIds.add(cardId);
2424
+ cards.push({
2425
+ cardId,
2426
+ courseId,
2427
+ score: BASE_PRACTICE_SCORE,
2428
+ provenance: [
2429
+ {
2430
+ strategy: "prescribed",
2431
+ strategyName: this.strategyName || this.name,
2432
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2433
+ action: "generated",
2434
+ score: BASE_PRACTICE_SCORE,
2435
+ reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2436
+ }
2437
+ ]
2438
+ });
2439
+ }
2440
+ return cards;
2441
+ }
2442
+ /**
2443
+ * True for a skill that was *gated and is now reached*: it has at least one
2444
+ * declared hierarchy prerequisite set, and every set is fully satisfied by the
2445
+ * learner's per-tag ELO. This deliberately EXCLUDES tags with no prerequisites
2446
+ * — an ungated tag was never "introduced" in the curricular sense, so it isn't
2447
+ * a post-intro drill target (e.g. whole-word spelling tags that share the
2448
+ * `gpc:exercise:*` prefix but have no intro gate). Those are left to normal
2449
+ * ELO retrieval. This is the precise population the retrieval gap strands:
2450
+ * just-unlocked, low-ELO skills.
2451
+ */
2452
+ isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) {
2453
+ const prereqSets = hierarchyConfigs.map((hierarchy) => hierarchy.prerequisites[tag]).filter((prereqs) => Array.isArray(prereqs) && prereqs.length > 0);
2454
+ if (prereqSets.length === 0) return false;
2455
+ return prereqSets.every(
2456
+ (prereqs) => prereqs.every((pr) => this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo))
2457
+ );
2458
+ }
2260
2459
  findSupportCardsByTags(group, tagsByCard, supportTags) {
2261
2460
  if (supportTags.length === 0) {
2262
2461
  return [];
@@ -3805,7 +4004,7 @@ function logResultCards(cards) {
3805
4004
  for (let i = 0; i < cards.length; i++) {
3806
4005
  const c = cards[i];
3807
4006
  const tags = c.tags?.slice(0, 3).join(", ") || "";
3808
- const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
4007
+ 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) => {
3809
4008
  const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
3810
4009
  return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
3811
4010
  }).join(" | ");
@@ -3836,6 +4035,7 @@ var init_Pipeline = __esm({
3836
4035
  init_logger();
3837
4036
  init_orchestration();
3838
4037
  init_PipelineDebugger();
4038
+ init_diversityRerank();
3839
4039
  VERBOSE_RESULTS = true;
3840
4040
  Pipeline = class extends ContentNavigator {
3841
4041
  generator;
@@ -4009,6 +4209,7 @@ var init_Pipeline = __esm({
4009
4209
  this._ephemeralHints = null;
4010
4210
  cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
4011
4211
  }
4212
+ cards = diversityRerank(cards);
4012
4213
  cards.sort((a, b) => b.score - a.score);
4013
4214
  const tFilter = performance.now();
4014
4215
  const result = cards.slice(0, limit);
@@ -4312,6 +4513,68 @@ var init_Pipeline = __esm({
4312
4513
  // ---------------------------------------------------------------------------
4313
4514
  // Card-space diagnostic
4314
4515
  // ---------------------------------------------------------------------------
4516
+ /**
4517
+ * Commit-free forecast: score the user's full card space through the filter
4518
+ * chain and return the cards that are currently *reachable* (score >=
4519
+ * threshold), optionally nudged by caller-supplied hints and/or restricted
4520
+ * to cards the user hasn't seen yet.
4521
+ *
4522
+ * This is a GENERIC primitive — it returns scored, tag-hydrated cards and
4523
+ * stops there. It has no knowledge of any particular tag convention; callers
4524
+ * decide what the surviving cards mean (e.g. filter to their own "intro"
4525
+ * tag family). Nothing is written and no session is started.
4526
+ *
4527
+ * The optional `hints` are the "out-of-band kick": they run through the same
4528
+ * {@link applyHints} path a live replan uses, so the two semantics carry over —
4529
+ * - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
4530
+ * stays out), and
4531
+ * - `requireTags`/`requireCards` inject from the full pre-filter pool,
4532
+ * *bypassing* gating (use when you want a card regardless of reachability).
4533
+ * Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
4534
+ * user has already seen — pass `unseenOnly: false` if that matters.
4535
+ *
4536
+ * Cost note: like {@link diagnoseCardSpace}, this scans every card through the
4537
+ * filters, so it's heavier than a normal replan. Intended for one-shot
4538
+ * out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
4539
+ *
4540
+ * @param opts.hints Optional ephemeral hints to apply after the filter chain.
4541
+ * @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
4542
+ * @param opts.threshold Min score to count as reachable (default 0.10).
4543
+ * @param opts.limit Optional cap on results (already sorted desc).
4544
+ */
4545
+ async forecast(opts) {
4546
+ const threshold = opts?.threshold ?? 0.1;
4547
+ const unseenOnly = opts?.unseenOnly ?? true;
4548
+ const courseId = this.course.getCourseID();
4549
+ const allCardIds = await this.course.getAllCardIds();
4550
+ let cards = allCardIds.map((cardId) => ({
4551
+ cardId,
4552
+ courseId,
4553
+ score: 1,
4554
+ provenance: []
4555
+ }));
4556
+ cards = await this.hydrateTags(cards);
4557
+ const fullPool = cards.slice();
4558
+ const context = await this.buildContext();
4559
+ for (const filter of this.filters) {
4560
+ cards = await filter.transform(cards, context);
4561
+ }
4562
+ if (opts?.hints) {
4563
+ cards = this.applyHints(cards, opts.hints, fullPool);
4564
+ }
4565
+ cards = cards.filter((c) => c.score >= threshold);
4566
+ if (unseenOnly) {
4567
+ let encountered;
4568
+ try {
4569
+ encountered = new Set(await this.user.getSeenCards(courseId));
4570
+ } catch {
4571
+ encountered = /* @__PURE__ */ new Set();
4572
+ }
4573
+ cards = cards.filter((c) => !encountered.has(c.cardId));
4574
+ }
4575
+ cards.sort((a, b) => b.score - a.score);
4576
+ return opts?.limit ? cards.slice(0, opts.limit) : cards;
4577
+ }
4315
4578
  /**
4316
4579
  * Scan every card in the course through the filter chain and report
4317
4580
  * how many are "well indicated" (score >= threshold) for the current user.
@@ -4577,6 +4840,7 @@ var init_3 = __esm({
4577
4840
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
4578
4841
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
4579
4842
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
4843
+ "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
4580
4844
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
4581
4845
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
4582
4846
  "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
@@ -4602,9 +4866,13 @@ var init_3 = __esm({
4602
4866
  var navigators_exports = {};
4603
4867
  __export(navigators_exports, {
4604
4868
  ContentNavigator: () => ContentNavigator,
4869
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
4870
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
4605
4871
  NavigatorRole: () => NavigatorRole,
4606
4872
  NavigatorRoles: () => NavigatorRoles,
4607
4873
  Navigators: () => Navigators,
4874
+ diversityRerank: () => diversityRerank,
4875
+ getActivePipeline: () => getActivePipeline,
4608
4876
  getCardOrigin: () => getCardOrigin,
4609
4877
  getRegisteredNavigator: () => getRegisteredNavigator,
4610
4878
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
@@ -4688,6 +4956,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
4688
4956
  var init_navigators = __esm({
4689
4957
  "src/core/navigators/index.ts"() {
4690
4958
  "use strict";
4959
+ init_diversityRerank();
4691
4960
  init_PipelineDebugger();
4692
4961
  init_logger();
4693
4962
  init_();