@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.
@@ -643,12 +643,102 @@ var init_courseLookupDB = __esm({
643
643
  }
644
644
  });
645
645
 
646
+ // src/core/navigators/diversityRerank.ts
647
+ var diversityRerank_exports = {};
648
+ __export(diversityRerank_exports, {
649
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
650
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
651
+ diversityRerank: () => diversityRerank
652
+ });
653
+ function diversityRerank(cards, opts = {}) {
654
+ const strength = opts.strength ?? DIVERSITY_STRENGTH;
655
+ const floor = opts.floor ?? DIVERSITY_FLOOR;
656
+ const n = cards.length;
657
+ if (n <= 1) return cards;
658
+ const df = /* @__PURE__ */ new Map();
659
+ for (const card of cards) {
660
+ for (const tag of card.tags ?? []) {
661
+ df.set(tag, (df.get(tag) ?? 0) + 1);
662
+ }
663
+ }
664
+ const idf = /* @__PURE__ */ new Map();
665
+ for (const [tag, freq] of df) {
666
+ idf.set(tag, Math.log(n / freq));
667
+ }
668
+ const remaining = [...cards];
669
+ const emittedCount = /* @__PURE__ */ new Map();
670
+ const out = [];
671
+ const repetitionLoad = (card) => {
672
+ let load = 0;
673
+ for (const tag of card.tags ?? []) {
674
+ const seen = emittedCount.get(tag);
675
+ if (seen) load += (idf.get(tag) ?? 0) * seen;
676
+ }
677
+ return load;
678
+ };
679
+ while (remaining.length > 0) {
680
+ let bestIdx = 0;
681
+ let bestValue = -Infinity;
682
+ let bestPenalty = 1;
683
+ let bestLoad = 0;
684
+ for (let i = 0; i < remaining.length; i++) {
685
+ const card = remaining[i];
686
+ const load = repetitionLoad(card);
687
+ const penalty = load > 0 ? Math.max(floor, 1 / (1 + strength * load)) : 1;
688
+ const value = card.score * penalty;
689
+ if (value > bestValue) {
690
+ bestValue = value;
691
+ bestIdx = i;
692
+ bestPenalty = penalty;
693
+ bestLoad = load;
694
+ }
695
+ }
696
+ const [picked] = remaining.splice(bestIdx, 1);
697
+ if (Number.isFinite(picked.score) && bestPenalty < 1) {
698
+ const newScore = picked.score * bestPenalty;
699
+ out.push({
700
+ ...picked,
701
+ score: newScore,
702
+ provenance: [
703
+ ...picked.provenance,
704
+ {
705
+ strategy: STRATEGY,
706
+ strategyId: STRATEGY_ID,
707
+ strategyName: STRATEGY_NAME,
708
+ action: "penalized",
709
+ score: newScore,
710
+ reason: `repeated tags (load ${bestLoad.toFixed(2)}) \u2192 \xD7${bestPenalty.toFixed(2)}`
711
+ }
712
+ ]
713
+ });
714
+ } else {
715
+ out.push(picked);
716
+ }
717
+ for (const tag of picked.tags ?? []) {
718
+ emittedCount.set(tag, (emittedCount.get(tag) ?? 0) + 1);
719
+ }
720
+ }
721
+ return out;
722
+ }
723
+ var DIVERSITY_STRENGTH, DIVERSITY_FLOOR, STRATEGY, STRATEGY_ID, STRATEGY_NAME;
724
+ var init_diversityRerank = __esm({
725
+ "src/core/navigators/diversityRerank.ts"() {
726
+ "use strict";
727
+ DIVERSITY_STRENGTH = 0.6;
728
+ DIVERSITY_FLOOR = 0.3;
729
+ STRATEGY = "diversityRerank";
730
+ STRATEGY_ID = "DIVERSITY_RERANK";
731
+ STRATEGY_NAME = "Diversity Re-rank";
732
+ }
733
+ });
734
+
646
735
  // src/core/navigators/PipelineDebugger.ts
647
736
  var PipelineDebugger_exports = {};
648
737
  __export(PipelineDebugger_exports, {
649
738
  buildRunReport: () => buildRunReport,
650
739
  captureRun: () => captureRun,
651
740
  clearRunHistory: () => clearRunHistory,
741
+ getActivePipeline: () => getActivePipeline,
652
742
  mountPipelineDebugger: () => mountPipelineDebugger,
653
743
  pipelineDebugAPI: () => pipelineDebugAPI,
654
744
  registerPipelineForDebug: () => registerPipelineForDebug
@@ -656,6 +746,9 @@ __export(PipelineDebugger_exports, {
656
746
  function registerPipelineForDebug(pipeline) {
657
747
  _activePipeline = pipeline;
658
748
  }
749
+ function getActivePipeline() {
750
+ return _activePipeline;
751
+ }
659
752
  function clearRunHistory() {
660
753
  runHistory.length = 0;
661
754
  }
@@ -1838,7 +1931,7 @@ function shuffleInPlace(arr) {
1838
1931
  function pickTopByScore(cards, limit) {
1839
1932
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1840
1933
  }
1841
- 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;
1934
+ 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;
1842
1935
  var init_prescribed = __esm({
1843
1936
  "src/core/navigators/generators/prescribed.ts"() {
1844
1937
  "use strict";
@@ -1849,9 +1942,12 @@ var init_prescribed = __esm({
1849
1942
  DEFAULT_MAX_SUPPORT_PER_RUN = 3;
1850
1943
  DEFAULT_HIERARCHY_DEPTH = 2;
1851
1944
  DEFAULT_MIN_COUNT = 3;
1945
+ DEFAULT_PRACTICE_MIN_COUNT = 3;
1946
+ DEFAULT_MAX_PRACTICE_PER_RUN = 4;
1852
1947
  BASE_TARGET_SCORE = 1;
1853
1948
  BASE_SUPPORT_SCORE = 0.8;
1854
1949
  DISCOVERED_SUPPORT_SCORE = 12;
1950
+ BASE_PRACTICE_SCORE = 1;
1855
1951
  MAX_TARGET_MULTIPLIER = 8;
1856
1952
  MAX_SUPPORT_MULTIPLIER = 4;
1857
1953
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -1959,7 +2055,18 @@ var init_prescribed = __esm({
1959
2055
  courseId,
1960
2056
  emittedIds
1961
2057
  );
1962
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
2058
+ const practiceCards = this.buildPracticeCards({
2059
+ group,
2060
+ courseId,
2061
+ emittedIds,
2062
+ cardsByTag,
2063
+ hierarchyConfigs,
2064
+ userTagElo,
2065
+ userGlobalElo,
2066
+ activeIds,
2067
+ seenIds
2068
+ });
2069
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
1963
2070
  }
1964
2071
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
1965
2072
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
@@ -1987,6 +2094,7 @@ var init_prescribed = __esm({
1987
2094
  const surfacedByGroup = /* @__PURE__ */ new Map();
1988
2095
  for (const card of finalCards) {
1989
2096
  const prov = card.provenance[0];
2097
+ if (prov?.reason.includes("mode=practice")) continue;
1990
2098
  const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
1991
2099
  const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
1992
2100
  if (!groupId) continue;
@@ -2056,7 +2164,12 @@ var init_prescribed = __esm({
2056
2164
  enabled: raw.hierarchyWalk?.enabled !== false,
2057
2165
  maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
2058
2166
  },
2059
- retireOnEncounter: raw.retireOnEncounter !== false
2167
+ retireOnEncounter: raw.retireOnEncounter !== false,
2168
+ practiceTagPatterns: dedupe(
2169
+ Array.isArray(raw.practiceTagPatterns) ? raw.practiceTagPatterns.filter((v) => typeof v === "string") : []
2170
+ ),
2171
+ practiceMinCount: typeof raw.practiceMinCount === "number" ? raw.practiceMinCount : DEFAULT_PRACTICE_MIN_COUNT,
2172
+ maxPracticeCardsPerRun: typeof raw.maxPracticeCardsPerRun === "number" ? raw.maxPracticeCardsPerRun : DEFAULT_MAX_PRACTICE_PER_RUN
2060
2173
  })).filter((g) => g.targetCardIds.length > 0);
2061
2174
  return { groups };
2062
2175
  } catch {
@@ -2279,6 +2392,92 @@ var init_prescribed = __esm({
2279
2392
  }
2280
2393
  return cards;
2281
2394
  }
2395
+ /**
2396
+ * Emit drill cards for *unlocked-but-under-practiced* skills.
2397
+ *
2398
+ * For each course tag matching the group's `practiceTagPatterns` that is both
2399
+ * unlocked (all hierarchy prerequisites met — i.e. the learner has been
2400
+ * introduced to it) and under-practiced (per-tag attempt count below
2401
+ * `practiceMinCount`), this resolves cards carrying that tag and emits them
2402
+ * into the candidate pool. It exists because global-ELO retrieval
2403
+ * systematically fails to fetch the (low-ELO) drill cards for a
2404
+ * freshly-introduced skill — putting them in the pool here lets the pipeline's
2405
+ * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2406
+ * this method's job; it only guarantees presence.
2407
+ *
2408
+ * Fully data-driven: the unlock relation comes from the hierarchy config and
2409
+ * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
2410
+ */
2411
+ buildPracticeCards(args) {
2412
+ const {
2413
+ group,
2414
+ courseId,
2415
+ emittedIds,
2416
+ cardsByTag,
2417
+ hierarchyConfigs,
2418
+ userTagElo,
2419
+ userGlobalElo,
2420
+ activeIds,
2421
+ seenIds
2422
+ } = args;
2423
+ const patterns = group.practiceTagPatterns ?? [];
2424
+ if (patterns.length === 0) return [];
2425
+ const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2426
+ const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2427
+ const practiceTags = [...cardsByTag.keys()].filter(
2428
+ (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2429
+ );
2430
+ if (practiceTags.length === 0) return [];
2431
+ const practiceCardIds = this.findDiscoveredSupportCards({
2432
+ supportTags: practiceTags,
2433
+ cardsByTag,
2434
+ activeIds,
2435
+ seenIds,
2436
+ excludedIds: emittedIds,
2437
+ limit: maxPractice
2438
+ });
2439
+ if (practiceCardIds.length === 0) return [];
2440
+ logger.info(
2441
+ `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2442
+ );
2443
+ const cards = [];
2444
+ for (const cardId of practiceCardIds) {
2445
+ emittedIds.add(cardId);
2446
+ cards.push({
2447
+ cardId,
2448
+ courseId,
2449
+ score: BASE_PRACTICE_SCORE,
2450
+ provenance: [
2451
+ {
2452
+ strategy: "prescribed",
2453
+ strategyName: this.strategyName || this.name,
2454
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2455
+ action: "generated",
2456
+ score: BASE_PRACTICE_SCORE,
2457
+ reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2458
+ }
2459
+ ]
2460
+ });
2461
+ }
2462
+ return cards;
2463
+ }
2464
+ /**
2465
+ * True for a skill that was *gated and is now reached*: it has at least one
2466
+ * declared hierarchy prerequisite set, and every set is fully satisfied by the
2467
+ * learner's per-tag ELO. This deliberately EXCLUDES tags with no prerequisites
2468
+ * — an ungated tag was never "introduced" in the curricular sense, so it isn't
2469
+ * a post-intro drill target (e.g. whole-word spelling tags that share the
2470
+ * `gpc:exercise:*` prefix but have no intro gate). Those are left to normal
2471
+ * ELO retrieval. This is the precise population the retrieval gap strands:
2472
+ * just-unlocked, low-ELO skills.
2473
+ */
2474
+ isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) {
2475
+ const prereqSets = hierarchyConfigs.map((hierarchy) => hierarchy.prerequisites[tag]).filter((prereqs) => Array.isArray(prereqs) && prereqs.length > 0);
2476
+ if (prereqSets.length === 0) return false;
2477
+ return prereqSets.every(
2478
+ (prereqs) => prereqs.every((pr) => this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo))
2479
+ );
2480
+ }
2282
2481
  findSupportCardsByTags(group, tagsByCard, supportTags) {
2283
2482
  if (supportTags.length === 0) {
2284
2483
  return [];
@@ -3826,7 +4025,7 @@ function logResultCards(cards) {
3826
4025
  for (let i = 0; i < cards.length; i++) {
3827
4026
  const c = cards[i];
3828
4027
  const tags = c.tags?.slice(0, 3).join(", ") || "";
3829
- const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
4028
+ 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) => {
3830
4029
  const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
3831
4030
  return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
3832
4031
  }).join(" | ");
@@ -3858,6 +4057,7 @@ var init_Pipeline = __esm({
3858
4057
  init_logger();
3859
4058
  init_orchestration();
3860
4059
  init_PipelineDebugger();
4060
+ init_diversityRerank();
3861
4061
  VERBOSE_RESULTS = true;
3862
4062
  Pipeline = class extends ContentNavigator {
3863
4063
  generator;
@@ -4031,6 +4231,7 @@ var init_Pipeline = __esm({
4031
4231
  this._ephemeralHints = null;
4032
4232
  cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
4033
4233
  }
4234
+ cards = diversityRerank(cards);
4034
4235
  cards.sort((a, b) => b.score - a.score);
4035
4236
  const tFilter = performance.now();
4036
4237
  const result = cards.slice(0, limit);
@@ -4334,6 +4535,68 @@ var init_Pipeline = __esm({
4334
4535
  // ---------------------------------------------------------------------------
4335
4536
  // Card-space diagnostic
4336
4537
  // ---------------------------------------------------------------------------
4538
+ /**
4539
+ * Commit-free forecast: score the user's full card space through the filter
4540
+ * chain and return the cards that are currently *reachable* (score >=
4541
+ * threshold), optionally nudged by caller-supplied hints and/or restricted
4542
+ * to cards the user hasn't seen yet.
4543
+ *
4544
+ * This is a GENERIC primitive — it returns scored, tag-hydrated cards and
4545
+ * stops there. It has no knowledge of any particular tag convention; callers
4546
+ * decide what the surviving cards mean (e.g. filter to their own "intro"
4547
+ * tag family). Nothing is written and no session is started.
4548
+ *
4549
+ * The optional `hints` are the "out-of-band kick": they run through the same
4550
+ * {@link applyHints} path a live replan uses, so the two semantics carry over —
4551
+ * - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
4552
+ * stays out), and
4553
+ * - `requireTags`/`requireCards` inject from the full pre-filter pool,
4554
+ * *bypassing* gating (use when you want a card regardless of reachability).
4555
+ * Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
4556
+ * user has already seen — pass `unseenOnly: false` if that matters.
4557
+ *
4558
+ * Cost note: like {@link diagnoseCardSpace}, this scans every card through the
4559
+ * filters, so it's heavier than a normal replan. Intended for one-shot
4560
+ * out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
4561
+ *
4562
+ * @param opts.hints Optional ephemeral hints to apply after the filter chain.
4563
+ * @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
4564
+ * @param opts.threshold Min score to count as reachable (default 0.10).
4565
+ * @param opts.limit Optional cap on results (already sorted desc).
4566
+ */
4567
+ async forecast(opts) {
4568
+ const threshold = opts?.threshold ?? 0.1;
4569
+ const unseenOnly = opts?.unseenOnly ?? true;
4570
+ const courseId = this.course.getCourseID();
4571
+ const allCardIds = await this.course.getAllCardIds();
4572
+ let cards = allCardIds.map((cardId) => ({
4573
+ cardId,
4574
+ courseId,
4575
+ score: 1,
4576
+ provenance: []
4577
+ }));
4578
+ cards = await this.hydrateTags(cards);
4579
+ const fullPool = cards.slice();
4580
+ const context = await this.buildContext();
4581
+ for (const filter of this.filters) {
4582
+ cards = await filter.transform(cards, context);
4583
+ }
4584
+ if (opts?.hints) {
4585
+ cards = this.applyHints(cards, opts.hints, fullPool);
4586
+ }
4587
+ cards = cards.filter((c) => c.score >= threshold);
4588
+ if (unseenOnly) {
4589
+ let encountered;
4590
+ try {
4591
+ encountered = new Set(await this.user.getSeenCards(courseId));
4592
+ } catch {
4593
+ encountered = /* @__PURE__ */ new Set();
4594
+ }
4595
+ cards = cards.filter((c) => !encountered.has(c.cardId));
4596
+ }
4597
+ cards.sort((a, b) => b.score - a.score);
4598
+ return opts?.limit ? cards.slice(0, opts.limit) : cards;
4599
+ }
4337
4600
  /**
4338
4601
  * Scan every card in the course through the filter chain and report
4339
4602
  * how many are "well indicated" (score >= threshold) for the current user.
@@ -4599,6 +4862,7 @@ var init_3 = __esm({
4599
4862
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
4600
4863
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
4601
4864
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
4865
+ "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
4602
4866
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
4603
4867
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
4604
4868
  "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
@@ -4624,9 +4888,13 @@ var init_3 = __esm({
4624
4888
  var navigators_exports = {};
4625
4889
  __export(navigators_exports, {
4626
4890
  ContentNavigator: () => ContentNavigator,
4891
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
4892
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
4627
4893
  NavigatorRole: () => NavigatorRole,
4628
4894
  NavigatorRoles: () => NavigatorRoles,
4629
4895
  Navigators: () => Navigators,
4896
+ diversityRerank: () => diversityRerank,
4897
+ getActivePipeline: () => getActivePipeline,
4630
4898
  getCardOrigin: () => getCardOrigin,
4631
4899
  getRegisteredNavigator: () => getRegisteredNavigator,
4632
4900
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
@@ -4710,6 +4978,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
4710
4978
  var init_navigators = __esm({
4711
4979
  "src/core/navigators/index.ts"() {
4712
4980
  "use strict";
4981
+ init_diversityRerank();
4713
4982
  init_PipelineDebugger();
4714
4983
  init_logger();
4715
4984
  init_();