@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.
@@ -522,12 +522,102 @@ var init_courseLookupDB = __esm({
522
522
  }
523
523
  });
524
524
 
525
+ // src/core/navigators/diversityRerank.ts
526
+ var diversityRerank_exports = {};
527
+ __export(diversityRerank_exports, {
528
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
529
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
530
+ diversityRerank: () => diversityRerank
531
+ });
532
+ function diversityRerank(cards, opts = {}) {
533
+ const strength = opts.strength ?? DIVERSITY_STRENGTH;
534
+ const floor = opts.floor ?? DIVERSITY_FLOOR;
535
+ const n = cards.length;
536
+ if (n <= 1) return cards;
537
+ const df = /* @__PURE__ */ new Map();
538
+ for (const card of cards) {
539
+ for (const tag of card.tags ?? []) {
540
+ df.set(tag, (df.get(tag) ?? 0) + 1);
541
+ }
542
+ }
543
+ const idf = /* @__PURE__ */ new Map();
544
+ for (const [tag, freq] of df) {
545
+ idf.set(tag, Math.log(n / freq));
546
+ }
547
+ const remaining = [...cards];
548
+ const emittedCount = /* @__PURE__ */ new Map();
549
+ const out = [];
550
+ const repetitionLoad = (card) => {
551
+ let load = 0;
552
+ for (const tag of card.tags ?? []) {
553
+ const seen = emittedCount.get(tag);
554
+ if (seen) load += (idf.get(tag) ?? 0) * seen;
555
+ }
556
+ return load;
557
+ };
558
+ while (remaining.length > 0) {
559
+ let bestIdx = 0;
560
+ let bestValue = -Infinity;
561
+ let bestPenalty = 1;
562
+ let bestLoad = 0;
563
+ for (let i = 0; i < remaining.length; i++) {
564
+ const card = remaining[i];
565
+ const load = repetitionLoad(card);
566
+ const penalty = load > 0 ? Math.max(floor, 1 / (1 + strength * load)) : 1;
567
+ const value = card.score * penalty;
568
+ if (value > bestValue) {
569
+ bestValue = value;
570
+ bestIdx = i;
571
+ bestPenalty = penalty;
572
+ bestLoad = load;
573
+ }
574
+ }
575
+ const [picked] = remaining.splice(bestIdx, 1);
576
+ if (Number.isFinite(picked.score) && bestPenalty < 1) {
577
+ const newScore = picked.score * bestPenalty;
578
+ out.push({
579
+ ...picked,
580
+ score: newScore,
581
+ provenance: [
582
+ ...picked.provenance,
583
+ {
584
+ strategy: STRATEGY,
585
+ strategyId: STRATEGY_ID,
586
+ strategyName: STRATEGY_NAME,
587
+ action: "penalized",
588
+ score: newScore,
589
+ reason: `repeated tags (load ${bestLoad.toFixed(2)}) \u2192 \xD7${bestPenalty.toFixed(2)}`
590
+ }
591
+ ]
592
+ });
593
+ } else {
594
+ out.push(picked);
595
+ }
596
+ for (const tag of picked.tags ?? []) {
597
+ emittedCount.set(tag, (emittedCount.get(tag) ?? 0) + 1);
598
+ }
599
+ }
600
+ return out;
601
+ }
602
+ var DIVERSITY_STRENGTH, DIVERSITY_FLOOR, STRATEGY, STRATEGY_ID, STRATEGY_NAME;
603
+ var init_diversityRerank = __esm({
604
+ "src/core/navigators/diversityRerank.ts"() {
605
+ "use strict";
606
+ DIVERSITY_STRENGTH = 0.6;
607
+ DIVERSITY_FLOOR = 0.3;
608
+ STRATEGY = "diversityRerank";
609
+ STRATEGY_ID = "DIVERSITY_RERANK";
610
+ STRATEGY_NAME = "Diversity Re-rank";
611
+ }
612
+ });
613
+
525
614
  // src/core/navigators/PipelineDebugger.ts
526
615
  var PipelineDebugger_exports = {};
527
616
  __export(PipelineDebugger_exports, {
528
617
  buildRunReport: () => buildRunReport,
529
618
  captureRun: () => captureRun,
530
619
  clearRunHistory: () => clearRunHistory,
620
+ getActivePipeline: () => getActivePipeline,
531
621
  mountPipelineDebugger: () => mountPipelineDebugger,
532
622
  pipelineDebugAPI: () => pipelineDebugAPI,
533
623
  registerPipelineForDebug: () => registerPipelineForDebug
@@ -535,6 +625,9 @@ __export(PipelineDebugger_exports, {
535
625
  function registerPipelineForDebug(pipeline) {
536
626
  _activePipeline = pipeline;
537
627
  }
628
+ function getActivePipeline() {
629
+ return _activePipeline;
630
+ }
538
631
  function clearRunHistory() {
539
632
  runHistory.length = 0;
540
633
  }
@@ -1717,7 +1810,7 @@ function shuffleInPlace(arr) {
1717
1810
  function pickTopByScore(cards, limit) {
1718
1811
  return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
1719
1812
  }
1720
- 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;
1813
+ 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;
1721
1814
  var init_prescribed = __esm({
1722
1815
  "src/core/navigators/generators/prescribed.ts"() {
1723
1816
  "use strict";
@@ -1728,9 +1821,12 @@ var init_prescribed = __esm({
1728
1821
  DEFAULT_MAX_SUPPORT_PER_RUN = 3;
1729
1822
  DEFAULT_HIERARCHY_DEPTH = 2;
1730
1823
  DEFAULT_MIN_COUNT = 3;
1824
+ DEFAULT_PRACTICE_MIN_COUNT = 3;
1825
+ DEFAULT_MAX_PRACTICE_PER_RUN = 4;
1731
1826
  BASE_TARGET_SCORE = 1;
1732
1827
  BASE_SUPPORT_SCORE = 0.8;
1733
1828
  DISCOVERED_SUPPORT_SCORE = 12;
1829
+ BASE_PRACTICE_SCORE = 1;
1734
1830
  MAX_TARGET_MULTIPLIER = 8;
1735
1831
  MAX_SUPPORT_MULTIPLIER = 4;
1736
1832
  PRESCRIBED_DEBUG_VERSION = "testversion-prescribed-v3";
@@ -1838,7 +1934,18 @@ var init_prescribed = __esm({
1838
1934
  courseId,
1839
1935
  emittedIds
1840
1936
  );
1841
- emitted.push(...directCards, ...supportCards, ...discoveredSupportCards);
1937
+ const practiceCards = this.buildPracticeCards({
1938
+ group,
1939
+ courseId,
1940
+ emittedIds,
1941
+ cardsByTag,
1942
+ hierarchyConfigs,
1943
+ userTagElo,
1944
+ userGlobalElo,
1945
+ activeIds,
1946
+ seenIds
1947
+ });
1948
+ emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practiceCards);
1842
1949
  }
1843
1950
  const hintSummary = this.buildSupportHintSummary(groupRuntimes);
1844
1951
  const hints = Object.keys(hintSummary.boostTags).length > 0 ? {
@@ -1866,6 +1973,7 @@ var init_prescribed = __esm({
1866
1973
  const surfacedByGroup = /* @__PURE__ */ new Map();
1867
1974
  for (const card of finalCards) {
1868
1975
  const prov = card.provenance[0];
1976
+ if (prov?.reason.includes("mode=practice")) continue;
1869
1977
  const groupId = prov?.reason.match(/group=([^;]+)/)?.[1];
1870
1978
  const mode = prov?.reason.includes("mode=support") ? "supportIds" : "targetIds";
1871
1979
  if (!groupId) continue;
@@ -1935,7 +2043,12 @@ var init_prescribed = __esm({
1935
2043
  enabled: raw.hierarchyWalk?.enabled !== false,
1936
2044
  maxDepth: typeof raw.hierarchyWalk?.maxDepth === "number" ? raw.hierarchyWalk.maxDepth : DEFAULT_HIERARCHY_DEPTH
1937
2045
  },
1938
- retireOnEncounter: raw.retireOnEncounter !== false
2046
+ retireOnEncounter: raw.retireOnEncounter !== false,
2047
+ practiceTagPatterns: dedupe(
2048
+ Array.isArray(raw.practiceTagPatterns) ? raw.practiceTagPatterns.filter((v) => typeof v === "string") : []
2049
+ ),
2050
+ practiceMinCount: typeof raw.practiceMinCount === "number" ? raw.practiceMinCount : DEFAULT_PRACTICE_MIN_COUNT,
2051
+ maxPracticeCardsPerRun: typeof raw.maxPracticeCardsPerRun === "number" ? raw.maxPracticeCardsPerRun : DEFAULT_MAX_PRACTICE_PER_RUN
1939
2052
  })).filter((g) => g.targetCardIds.length > 0);
1940
2053
  return { groups };
1941
2054
  } catch {
@@ -2158,6 +2271,92 @@ var init_prescribed = __esm({
2158
2271
  }
2159
2272
  return cards;
2160
2273
  }
2274
+ /**
2275
+ * Emit drill cards for *unlocked-but-under-practiced* skills.
2276
+ *
2277
+ * For each course tag matching the group's `practiceTagPatterns` that is both
2278
+ * unlocked (all hierarchy prerequisites met — i.e. the learner has been
2279
+ * introduced to it) and under-practiced (per-tag attempt count below
2280
+ * `practiceMinCount`), this resolves cards carrying that tag and emits them
2281
+ * into the candidate pool. It exists because global-ELO retrieval
2282
+ * systematically fails to fetch the (low-ELO) drill cards for a
2283
+ * freshly-introduced skill — putting them in the pool here lets the pipeline's
2284
+ * scoring + the durable per-skill boost order them. Ordering/emphasis is NOT
2285
+ * this method's job; it only guarantees presence.
2286
+ *
2287
+ * Fully data-driven: the unlock relation comes from the hierarchy config and
2288
+ * practice-status from per-tag ELO. No card-id or tag-namespace hard-coding.
2289
+ */
2290
+ buildPracticeCards(args) {
2291
+ const {
2292
+ group,
2293
+ courseId,
2294
+ emittedIds,
2295
+ cardsByTag,
2296
+ hierarchyConfigs,
2297
+ userTagElo,
2298
+ userGlobalElo,
2299
+ activeIds,
2300
+ seenIds
2301
+ } = args;
2302
+ const patterns = group.practiceTagPatterns ?? [];
2303
+ if (patterns.length === 0) return [];
2304
+ const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
2305
+ const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
2306
+ const practiceTags = [...cardsByTag.keys()].filter(
2307
+ (tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
2308
+ );
2309
+ if (practiceTags.length === 0) return [];
2310
+ const practiceCardIds = this.findDiscoveredSupportCards({
2311
+ supportTags: practiceTags,
2312
+ cardsByTag,
2313
+ activeIds,
2314
+ seenIds,
2315
+ excludedIds: emittedIds,
2316
+ limit: maxPractice
2317
+ });
2318
+ if (practiceCardIds.length === 0) return [];
2319
+ logger.info(
2320
+ `[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
2321
+ );
2322
+ const cards = [];
2323
+ for (const cardId of practiceCardIds) {
2324
+ emittedIds.add(cardId);
2325
+ cards.push({
2326
+ cardId,
2327
+ courseId,
2328
+ score: BASE_PRACTICE_SCORE,
2329
+ provenance: [
2330
+ {
2331
+ strategy: "prescribed",
2332
+ strategyName: this.strategyName || this.name,
2333
+ strategyId: this.strategyId || "NAVIGATION_STRATEGY-prescribed",
2334
+ action: "generated",
2335
+ score: BASE_PRACTICE_SCORE,
2336
+ reason: `mode=practice;group=${group.id};underPracticedSkills=${practiceTags.length};practiceTags=${practiceTags.slice(0, 8).join("|")}${practiceTags.length > 8 ? "|\u2026" : ""};testversion=${PRESCRIBED_DEBUG_VERSION}`
2337
+ }
2338
+ ]
2339
+ });
2340
+ }
2341
+ return cards;
2342
+ }
2343
+ /**
2344
+ * True for a skill that was *gated and is now reached*: it has at least one
2345
+ * declared hierarchy prerequisite set, and every set is fully satisfied by the
2346
+ * learner's per-tag ELO. This deliberately EXCLUDES tags with no prerequisites
2347
+ * — an ungated tag was never "introduced" in the curricular sense, so it isn't
2348
+ * a post-intro drill target (e.g. whole-word spelling tags that share the
2349
+ * `gpc:exercise:*` prefix but have no intro gate). Those are left to normal
2350
+ * ELO retrieval. This is the precise population the retrieval gap strands:
2351
+ * just-unlocked, low-ELO skills.
2352
+ */
2353
+ isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) {
2354
+ const prereqSets = hierarchyConfigs.map((hierarchy) => hierarchy.prerequisites[tag]).filter((prereqs) => Array.isArray(prereqs) && prereqs.length > 0);
2355
+ if (prereqSets.length === 0) return false;
2356
+ return prereqSets.every(
2357
+ (prereqs) => prereqs.every((pr) => this.isPrerequisiteMet(pr, userTagElo[pr.tag], userGlobalElo))
2358
+ );
2359
+ }
2161
2360
  findSupportCardsByTags(group, tagsByCard, supportTags) {
2162
2361
  if (supportTags.length === 0) {
2163
2362
  return [];
@@ -3705,7 +3904,7 @@ function logResultCards(cards) {
3705
3904
  for (let i = 0; i < cards.length; i++) {
3706
3905
  const c = cards[i];
3707
3906
  const tags = c.tags?.slice(0, 3).join(", ") || "";
3708
- const filters = c.provenance.filter((p) => p.strategy === "hierarchyDefinition" || p.strategy === "priorityDefinition" || p.strategy === "interferenceFilter" || p.strategy === "letterGating" || p.strategy === "ephemeralHint").map((p) => {
3907
+ 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) => {
3709
3908
  const arrow = p.action === "boosted" ? "\u2191" : p.action === "penalized" ? "\u2193" : "=";
3710
3909
  return `${p.strategyName}${arrow}${p.score.toFixed(2)}`;
3711
3910
  }).join(" | ");
@@ -3737,6 +3936,7 @@ var init_Pipeline = __esm({
3737
3936
  init_logger();
3738
3937
  init_orchestration();
3739
3938
  init_PipelineDebugger();
3939
+ init_diversityRerank();
3740
3940
  VERBOSE_RESULTS = true;
3741
3941
  Pipeline = class extends ContentNavigator {
3742
3942
  generator;
@@ -3910,6 +4110,7 @@ var init_Pipeline = __esm({
3910
4110
  this._ephemeralHints = null;
3911
4111
  cards = this.applyHints(cards, hints, allCardsBeforeFiltering);
3912
4112
  }
4113
+ cards = diversityRerank(cards);
3913
4114
  cards.sort((a, b) => b.score - a.score);
3914
4115
  const tFilter = performance.now();
3915
4116
  const result = cards.slice(0, limit);
@@ -4213,6 +4414,68 @@ var init_Pipeline = __esm({
4213
4414
  // ---------------------------------------------------------------------------
4214
4415
  // Card-space diagnostic
4215
4416
  // ---------------------------------------------------------------------------
4417
+ /**
4418
+ * Commit-free forecast: score the user's full card space through the filter
4419
+ * chain and return the cards that are currently *reachable* (score >=
4420
+ * threshold), optionally nudged by caller-supplied hints and/or restricted
4421
+ * to cards the user hasn't seen yet.
4422
+ *
4423
+ * This is a GENERIC primitive — it returns scored, tag-hydrated cards and
4424
+ * stops there. It has no knowledge of any particular tag convention; callers
4425
+ * decide what the surviving cards mean (e.g. filter to their own "intro"
4426
+ * tag family). Nothing is written and no session is started.
4427
+ *
4428
+ * The optional `hints` are the "out-of-band kick": they run through the same
4429
+ * {@link applyHints} path a live replan uses, so the two semantics carry over —
4430
+ * - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card
4431
+ * stays out), and
4432
+ * - `requireTags`/`requireCards` inject from the full pre-filter pool,
4433
+ * *bypassing* gating (use when you want a card regardless of reachability).
4434
+ * Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the
4435
+ * user has already seen — pass `unseenOnly: false` if that matters.
4436
+ *
4437
+ * Cost note: like {@link diagnoseCardSpace}, this scans every card through the
4438
+ * filters, so it's heavier than a normal replan. Intended for one-shot
4439
+ * out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path.
4440
+ *
4441
+ * @param opts.hints Optional ephemeral hints to apply after the filter chain.
4442
+ * @param opts.unseenOnly Only return cards the user hasn't encountered (default true).
4443
+ * @param opts.threshold Min score to count as reachable (default 0.10).
4444
+ * @param opts.limit Optional cap on results (already sorted desc).
4445
+ */
4446
+ async forecast(opts) {
4447
+ const threshold = opts?.threshold ?? 0.1;
4448
+ const unseenOnly = opts?.unseenOnly ?? true;
4449
+ const courseId = this.course.getCourseID();
4450
+ const allCardIds = await this.course.getAllCardIds();
4451
+ let cards = allCardIds.map((cardId) => ({
4452
+ cardId,
4453
+ courseId,
4454
+ score: 1,
4455
+ provenance: []
4456
+ }));
4457
+ cards = await this.hydrateTags(cards);
4458
+ const fullPool = cards.slice();
4459
+ const context = await this.buildContext();
4460
+ for (const filter of this.filters) {
4461
+ cards = await filter.transform(cards, context);
4462
+ }
4463
+ if (opts?.hints) {
4464
+ cards = this.applyHints(cards, opts.hints, fullPool);
4465
+ }
4466
+ cards = cards.filter((c) => c.score >= threshold);
4467
+ if (unseenOnly) {
4468
+ let encountered;
4469
+ try {
4470
+ encountered = new Set(await this.user.getSeenCards(courseId));
4471
+ } catch {
4472
+ encountered = /* @__PURE__ */ new Set();
4473
+ }
4474
+ cards = cards.filter((c) => !encountered.has(c.cardId));
4475
+ }
4476
+ cards.sort((a, b) => b.score - a.score);
4477
+ return opts?.limit ? cards.slice(0, opts.limit) : cards;
4478
+ }
4216
4479
  /**
4217
4480
  * Scan every card in the course through the filter chain and report
4218
4481
  * how many are "well indicated" (score >= threshold) for the current user.
@@ -4478,6 +4741,7 @@ var init_3 = __esm({
4478
4741
  "./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
4479
4742
  "./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
4480
4743
  "./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
4744
+ "./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
4481
4745
  "./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
4482
4746
  "./filters/eloDistance.ts": () => Promise.resolve().then(() => (init_eloDistance(), eloDistance_exports)),
4483
4747
  "./filters/hierarchyDefinition.ts": () => Promise.resolve().then(() => (init_hierarchyDefinition(), hierarchyDefinition_exports)),
@@ -4503,9 +4767,13 @@ var init_3 = __esm({
4503
4767
  var navigators_exports = {};
4504
4768
  __export(navigators_exports, {
4505
4769
  ContentNavigator: () => ContentNavigator,
4770
+ DIVERSITY_FLOOR: () => DIVERSITY_FLOOR,
4771
+ DIVERSITY_STRENGTH: () => DIVERSITY_STRENGTH,
4506
4772
  NavigatorRole: () => NavigatorRole,
4507
4773
  NavigatorRoles: () => NavigatorRoles,
4508
4774
  Navigators: () => Navigators,
4775
+ diversityRerank: () => diversityRerank,
4776
+ getActivePipeline: () => getActivePipeline,
4509
4777
  getCardOrigin: () => getCardOrigin,
4510
4778
  getRegisteredNavigator: () => getRegisteredNavigator,
4511
4779
  getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
@@ -4589,6 +4857,7 @@ var navigatorRegistry, Navigators, NavigatorRole, NavigatorRoles, ContentNavigat
4589
4857
  var init_navigators = __esm({
4590
4858
  "src/core/navigators/index.ts"() {
4591
4859
  "use strict";
4860
+ init_diversityRerank();
4592
4861
  init_PipelineDebugger();
4593
4862
  init_logger();
4594
4863
  init_();