import { toCourseElo } from '@vue-skuilder/common'; import type { CourseDBInterface } from '../interfaces/courseDB'; import type { UserDBInterface } from '../interfaces/userDB'; import { ContentNavigator } from './index'; import type { WeightedCard } from './index'; import type { CardFilter, FilterContext } from './filters/types'; import type { CardGenerator, GeneratorContext } from './generators/types'; import type { GeneratorResult } from './generators/types'; import { logger } from '../../util/logger'; import { createOrchestrationContext, OrchestrationContext } from '../orchestration'; import { captureRun, buildRunReport, registerPipelineForDebug, type GeneratorSummary, type FilterImpact } from './PipelineDebugger'; import { diversityRerank } from './diversityRerank'; // ============================================================================ // REPLAN HINTS // ============================================================================ // // Ephemeral, one-shot scoring hints passed at replan time. // Applied after the filter chain, consumed after one pipeline run. // // Tag patterns support glob-style matching: // 'gpc:exercise:t-T' — exact match // 'gpc:intro:*' — all intro tags // 'gpc:exercise:t-*' — all t-variant exercises // // ReplanHints is defined in generators/types — re-export for consumers that import from Pipeline import type { ReplanHints } from './generators/types'; export type { ReplanHints }; /** * Convert a glob pattern (with `*` wildcards) to a RegExp. * Only `*` is supported as a wildcard (matches any characters). */ function globToRegex(pattern: string): RegExp { const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); const withWildcards = escaped.replace(/\*/g, '.*'); return new RegExp(`^${withWildcards}$`); } /** Test whether a string matches a glob pattern. */ function globMatch(value: string, pattern: string): boolean { if (!pattern.includes('*')) return value === pattern; return globToRegex(pattern).test(value); } /** Test whether any of a card's tags match a glob pattern. */ function cardMatchesTagPattern(card: WeightedCard, pattern: string): boolean { return (card.tags ?? []).some((tag) => globMatch(tag, pattern)); } export function mergeHints(allHints: Array): ReplanHints | undefined { const defined = allHints.filter((h): h is ReplanHints => h !== null && h !== undefined); if (defined.length === 0) return undefined; const merged: ReplanHints = {}; const boostTags: Record = {}; for (const hints of defined) { for (const [pattern, factor] of Object.entries(hints.boostTags ?? {})) { boostTags[pattern] = (boostTags[pattern] ?? 1) * factor; } } if (Object.keys(boostTags).length > 0) { merged.boostTags = boostTags; } const boostCards: Record = {}; for (const hints of defined) { for (const [pattern, factor] of Object.entries(hints.boostCards ?? {})) { boostCards[pattern] = (boostCards[pattern] ?? 1) * factor; } } if (Object.keys(boostCards).length > 0) { merged.boostCards = boostCards; } const concatUnique = ( field: 'requireTags' | 'requireCards' | 'excludeTags' | 'excludeCards' ): void => { const values = defined.flatMap((h) => h[field] ?? []); if (values.length > 0) { merged[field] = [...new Set(values)]; } }; concatUnique('requireTags'); concatUnique('requireCards'); concatUnique('excludeTags'); concatUnique('excludeCards'); const labels = defined.map((h) => h._label).filter(Boolean); if (labels.length > 0) { merged._label = labels.join('; '); } return Object.keys(merged).length > 0 ? merged : undefined; } // ============================================================================ // PIPELINE LOGGING HELPERS // ============================================================================ // // Focused logging functions that can be toggled by commenting single lines. // Use these to inspect pipeline behavior in development/production. // /** * Log pipeline configuration on construction. * Shows generator and filter chain structure. */ function logPipelineConfig(generator: CardGenerator, filters: CardFilter[]): void { const filterList = filters.length > 0 ? '\n - ' + filters.map((f) => f.name).join('\n - ') : ' none'; logger.info( `[Pipeline] Configuration:\n` + ` Generator: ${generator.name}\n` + ` Filters:${filterList}` ); } /** * Log tag hydration results. * Shows effectiveness of batch query (how many cards/tags were hydrated). */ function logTagHydration(cards: WeightedCard[], tagsByCard: Map): void { const totalTags = Array.from(tagsByCard.values()).reduce((sum, tags) => sum + tags.length, 0); const cardsWithTags = Array.from(tagsByCard.values()).filter((tags) => tags.length > 0).length; logger.debug( `[Pipeline] Tag hydration: ${cards.length} cards, ` + `${cardsWithTags} have tags (${totalTags} total tags) - single batch query` ); } /** * Log pipeline execution summary. * Shows complete flow from generator through filters to final results. */ function logExecutionSummary( generatorName: string, generatedCount: number, filterCount: number, finalCount: number, topScores: number[], filterImpacts: Array<{ name: string; boosted: number; penalized: number; passed: number }> ): void { const scoreDisplay = topScores.length > 0 ? topScores.map((s) => s.toFixed(2)).join(', ') : 'none'; let filterSummary = ''; if (filterImpacts.length > 0) { const impacts = filterImpacts.map((f) => { const parts: string[] = []; if (f.boosted > 0) parts.push(`+${f.boosted}`); if (f.penalized > 0) parts.push(`-${f.penalized}`); if (f.passed > 0) parts.push(`=${f.passed}`); return `${f.name}: ${parts.join('/')}`; }); filterSummary = `\n Filter impact: ${impacts.join(', ')}`; } logger.info( `[Pipeline] Execution: ${generatorName} produced ${generatedCount} → ` + `${filterCount} filters → ${finalCount} results (top scores: ${scoreDisplay})` + filterSummary + `\n 💡 Inspect: window.skuilder.pipeline` ); } /** * Log all result cards with score, cardId, and key provenance. * Toggle: set VERBOSE_RESULTS = true to enable. */ const VERBOSE_RESULTS = true; function logResultCards(cards: WeightedCard[]): void { if (!VERBOSE_RESULTS || cards.length === 0) return; logger.info(`[Pipeline] Results (${cards.length} cards):`); for (let i = 0; i < cards.length; i++) { const c = cards[i]; const tags = c.tags?.slice(0, 3).join(', ') || ''; 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) => { const arrow = p.action === 'boosted' ? '↑' : p.action === 'penalized' ? '↓' : '='; return `${p.strategyName}${arrow}${p.score.toFixed(2)}`; }) .join(' | '); logger.info( `[Pipeline] ${String(i + 1).padStart(2)}. ${c.score.toFixed(4)} ${c.cardId} [${tags}]${filters ? ` {${filters}}` : ''}` ); } } /** * Log provenance trails for cards. * Shows the complete scoring history for each card through the pipeline. * Useful for debugging why cards scored the way they did. */ function logCardProvenance(cards: WeightedCard[], maxCards: number = 3): void { const cardsToLog = cards.slice(0, maxCards); logger.debug(`[Pipeline] Provenance for top ${cardsToLog.length} cards:`); for (const card of cardsToLog) { logger.debug(`[Pipeline] ${card.cardId} (final score: ${card.score.toFixed(3)}):`); for (const entry of card.provenance) { const scoreChange = entry.score.toFixed(3); const action = entry.action.padEnd(9); // Align columns logger.debug( `[Pipeline] ${action} ${scoreChange} - ${entry.strategyName}: ${entry.reason}` ); } } } // ============================================================================ // PIPELINE // ============================================================================ // // Executes a navigation pipeline: generator → filters → sorted results. // // Architecture: // cards = generator.getWeightedCards(limit, context) // cards = filter1.transform(cards, context) // cards = filter2.transform(cards, context) // cards = filter3.transform(cards, context) // return sorted(cards).slice(0, limit) // // Benefits: // - Clear separation: generators produce, filters transform // - No nested instantiation complexity // - Filters don't need to know about each other // - Shared context built once, passed to all stages // // ============================================================================ /** * A navigation pipeline that runs a generator and applies filters sequentially. * * Implements StudyContentSource for backward compatibility with SessionController. * * ## Usage * * ```typescript * const pipeline = new Pipeline( * compositeGenerator, // or single generator * [eloDistanceFilter, interferenceFilter], * user, * course * ); * * const cards = await pipeline.getWeightedCards(20); * ``` */ /** * Narrow capability surface for out-of-band, commit-free reads against a live * pipeline (see {@link getActivePipeline}). Kept minimal on purpose — consumers * get the forecast capability, not the whole `Pipeline` class. */ export interface PipelineForecaster { forecast(opts?: { hints?: ReplanHints; unseenOnly?: boolean; threshold?: number; limit?: number; }): Promise; } export class Pipeline extends ContentNavigator implements PipelineForecaster { private generator: CardGenerator; private filters: CardFilter[]; /** * Cached orchestration context. Course config and salt don't change within * a page load, so we build the orchestration context once and reuse it on * subsequent getWeightedCards() calls (e.g. mid-session replans). * * This eliminates a remote getCourseConfig() round trip per pipeline run. */ private _cachedOrchestration: OrchestrationContext | null = null; /** * Persistent tag cache. Maps cardId → tag names. * * Tags are static within a session (they're set at card generation time), * so we cache them across pipeline runs. On replans, many of the same cards * reappear — cache hits avoid redundant remote getAppliedTagsBatch() queries. */ private _tagCache: Map = new Map(); /** * One-shot replan hints. Applied after the filter chain on the next * getWeightedCards() call, then cleared. */ private _ephemeralHints: ReplanHints | null = null; /** * Create a new pipeline. * * @param generator - The generator (or CompositeGenerator) that produces candidates * @param filters - Filters to apply sequentially (order doesn't matter for multipliers) * @param user - User database interface * @param course - Course database interface */ constructor( generator: CardGenerator, filters: CardFilter[], user: UserDBInterface, course: CourseDBInterface ) { super(); this.generator = generator; this.filters = filters; this.user = user; this.course = course; course .getCourseConfig() .then((cfg) => { logger.debug(`[pipeline] Crated pipeline for ${cfg.name}`); }) .catch((e) => { logger.error(`[pipeline] Failed to lookup courseCfg: ${e}`); }); // Toggle pipeline configuration logging: logPipelineConfig(generator, filters); // Register for debug API access registerPipelineForDebug(this); } /** * Set one-shot hints for the next pipeline run. * Consumed after one getWeightedCards() call, then cleared. * * Overrides ContentNavigator.setEphemeralHints() no-op. */ override setEphemeralHints(hints: ReplanHints): void { this._ephemeralHints = hints; logger.info(`[Pipeline] Ephemeral hints set: ${JSON.stringify(hints)}`); } /** * Get weighted cards by running generator and applying filters. * * 1. Build shared context (user ELO, etc.) * 2. Get candidates from generator (passing context) * 3. Batch hydrate tags for all candidates * 4. Apply each filter sequentially * 5. Remove zero-score cards * 6. Sort by score descending * 7. Return top N * * @param limit - Maximum number of cards to return * @returns Cards sorted by score descending */ async getWeightedCards(limit: number): Promise { const t0 = performance.now(); // Build shared context once const context = await this.buildContext(); const tContext = performance.now(); // Over-fetch from generator to give filters a wide candidate pool. // With local course DB the cost is negligible (~20ms for 500 cards). // Filters (hierarchy, letter gating, etc.) can be aggressive — a wide // pool ensures enough well-indicated candidates survive. const fetchLimit = 500; logger.debug( `[Pipeline] Fetching ${fetchLimit} candidates from generator '${this.generator.name}'` ); // Get candidates from generator, passing context const generatorResult = await this.generator.getWeightedCards(fetchLimit, context); let cards = generatorResult.cards; const tGenerate = performance.now(); const generatedCount = cards.length; // Merge generator-emitted hints with any externally supplied one-shot hints const mergedHints = mergeHints([this._ephemeralHints, generatorResult.hints]); this._ephemeralHints = mergedHints ?? null; // Capture generator breakdown for debugging (if CompositeGenerator) let generatorSummaries: GeneratorSummary[] | undefined; if ((this.generator as any).generators) { // This is a CompositeGenerator - extract per-generator info from provenance const genMap = new Map(); for (const card of cards) { const firstProv = card.provenance[0]; if (firstProv) { const genName = firstProv.strategyName; if (!genMap.has(genName)) { genMap.set(genName, { cards: [] }); } genMap.get(genName)!.cards.push(card); } } generatorSummaries = Array.from(genMap.entries()).map(([name, data]) => { const newCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes('new card')); const reviewCards = data.cards.filter((c) => c.provenance[0]?.reason?.includes('review')); return { name, cardCount: data.cards.length, newCount: newCards.length, reviewCount: reviewCards.length, topScore: Math.max(...data.cards.map((c) => c.score), 0), }; }); } logger.debug(`[Pipeline] Generator returned ${generatedCount} candidates`); // Batch hydrate tags before filters run cards = await this.hydrateTags(cards); const tHydrate = performance.now(); // Keep a copy of all cards for debug capture (before filtering removes any) const allCardsBeforeFiltering = [...cards]; // Pre-fetch any literal requireCards IDs that the generator didn't produce. // requireCards is a hard guarantee — required cards bypass the filter chain // and are injected directly into the final result by applyHints. We need // them present in allCardsBeforeFiltering for that injection to find them. // (Glob patterns are left to the existing pool-search behaviour.) const pendingHints = this._ephemeralHints; if (pendingHints?.requireCards?.length) { const poolIds = new Set(allCardsBeforeFiltering.map((c) => c.cardId)); const missingIds = pendingHints.requireCards.filter( (p) => !p.includes('*') && !poolIds.has(p) ); if (missingIds.length > 0) { const fetchedTags = await this.course!.getAppliedTagsBatch(missingIds); const courseId = this.course!.getCourseID(); for (const cardId of missingIds) { allCardsBeforeFiltering.push({ cardId, courseId, score: 1.0, tags: fetchedTags.get(cardId) ?? [], provenance: [], }); } logger.info( `[Pipeline] Pre-fetched ${missingIds.length} required card(s) into pool: ${missingIds.join(', ')}` ); } } // Apply filters sequentially, tracking impact // Track prescribed-origin cards through the filter chain for diagnostics const prescribedIds = new Set( cards .filter((c) => c.provenance.some((p) => p.strategy === 'prescribed')) .map((c) => c.cardId) ); const filterImpacts: FilterImpact[] = []; // [perf] parked 2026-05 (pipeline-docs-workup) — uncomment to re-measure // const filterTimings: string[] = []; for (const filter of this.filters) { // const tFilterStart = performance.now(); const beforeCount = cards.length; const beforeScores = new Map(cards.map((c) => [c.cardId, c.score])); cards = await filter.transform(cards, context); // filterTimings.push(`${filter.name}=${(performance.now() - tFilterStart).toFixed(0)}ms`); // Count boost/penalize/pass/removed for this filter let boosted = 0, penalized = 0, passed = 0; const removed = beforeCount - cards.length; for (const card of cards) { const before = beforeScores.get(card.cardId) ?? 0; if (card.score > before) boosted++; else if (card.score < before) penalized++; else passed++; } filterImpacts.push({ name: filter.name, boosted, penalized, passed, removed }); // Report prescribed card fate through each filter if (prescribedIds.size > 0) { const survivingIds = new Set(cards.map((c) => c.cardId)); const killedPrescribed = [...prescribedIds].filter((id) => !survivingIds.has(id)); const zeroedPrescribed = cards .filter((c) => prescribedIds.has(c.cardId) && c.score === 0) .map((c) => c.cardId); if (killedPrescribed.length > 0 || zeroedPrescribed.length > 0) { logger.info( `[Pipeline] Filter '${filter.name}' impact on prescribed cards: ` + (killedPrescribed.length > 0 ? `removed=[${killedPrescribed.join(', ')}] ` : '') + (zeroedPrescribed.length > 0 ? `zeroed=[${zeroedPrescribed.join(', ')}]` : '') ); // Remove killed ones from tracking set killedPrescribed.forEach((id) => prescribedIds.delete(id)); } } logger.debug(`[Pipeline] Filter '${filter.name}': ${beforeScores.size} → ${cards.length} cards (↑${boosted} ↓${penalized} =${passed})`); } // Remove zero-score cards (hard filtered) cards = cards.filter((c) => c.score > 0); // Apply ephemeral hints (one-shot, post-filter) const hints = this._ephemeralHints; if (hints) { this._ephemeralHints = null; // consume cards = this.applyHints(cards, hints, allCardsBeforeFiltering); } // Stage 3: diversity re-rank (post-filter, post-hints, pre-sort). Demotes // cards whose distinctive tags already appeared among higher-ranked // candidates so a single answer/concept can't monopolise the head of the // queue. Tag-agnostic (rarity-weighted overlap — no namespace privileged) // and expressed as score penalties so the ordering survives the final sort // here AND the SourceMixer's score-descending re-sort downstream. cards = diversityRerank(cards); // Sort by score descending cards.sort((a, b) => b.score - a.score); // Return top N const tFilter = performance.now(); const result = cards.slice(0, limit); logger.info( `[Pipeline:timing] total=${(tFilter - t0).toFixed(0)}ms ` + `(context=${(tContext - t0).toFixed(0)} generate=${(tGenerate - tContext).toFixed(0)} ` + `hydrate=${(tHydrate - tGenerate).toFixed(0)} filter=${(tFilter - tHydrate).toFixed(0)})` ); // Toggle execution summary logging: const topScores = result.slice(0, 3).map((c) => c.score); logExecutionSummary( this.generator.name, generatedCount, this.filters.length, result.length, topScores, filterImpacts ); // Toggle verbose result listing: logResultCards(result); // Toggle provenance logging (shows scoring history for top cards): logCardProvenance(result, 3); // Capture run for debug API try { const courseName = await this.course?.getCourseConfig().then((c) => c.name).catch(() => undefined); // Pass the full post-filter sorted array; buildRunReport retains all // selected cards plus the top-N highest-scoring near-misses and // summarizes the discarded tail (see DISCARDED_KEEP_TOP). This keeps // showCard() useful for "why didn't this rank?" inspection without // pinning hundreds of low-score candidates' provenance per run. // `cards` is the post-filter, post-hints, sorted array. const report = buildRunReport( this.course?.getCourseID() || 'unknown', courseName, this.generator.name, generatorSummaries, generatedCount, filterImpacts, cards, result, context.userElo, hints?? undefined ); captureRun(report); } catch (e) { logger.debug(`[Pipeline] Failed to capture debug run: ${e}`); } return { cards: result }; } /** * Batch hydrate tags for all cards. * * Fetches tags for all cards in a single database query and attaches them * to the WeightedCard objects. Filters can then use card.tags instead of * making individual getAppliedTags() calls. * * Uses a persistent tag cache across pipeline runs — tags are static within * a session, so cards seen in a prior run (e.g. before a replan) don't * require a second DB query. * * @param cards - Cards to hydrate * @returns Cards with tags populated */ private async hydrateTags(cards: WeightedCard[]): Promise { if (cards.length === 0) { return cards; } // Separate cards with cached tags from those needing a DB query const uncachedIds: string[] = []; for (const card of cards) { if (!this._tagCache.has(card.cardId)) { uncachedIds.push(card.cardId); } } // Only query the DB for cards not already in cache if (uncachedIds.length > 0) { const freshTags = await this.course!.getAppliedTagsBatch(uncachedIds); for (const [cardId, tags] of freshTags) { this._tagCache.set(cardId, tags); } } // Build the tagsByCard map from cache (for logging compatibility) const tagsByCard = new Map(); for (const card of cards) { tagsByCard.set(card.cardId, this._tagCache.get(card.cardId) ?? []); } // Toggle tag hydration logging: logTagHydration(cards, tagsByCard); return cards.map((card) => ({ ...card, tags: this._tagCache.get(card.cardId) ?? [], })); } // --------------------------------------------------------------------------- // Ephemeral hints application // --------------------------------------------------------------------------- /** * Apply one-shot replan hints to the post-filter card set. * * Order of operations: * 1. Exclude (remove unwanted cards) * 2. Boost (multiply scores) * 3. Require (inject must-have cards from the full pre-filter pool) * * @param cards - Post-filter cards (score > 0) * @param hints - The ephemeral hints to apply * @param allCards - Full pre-filter card pool (for require injection) */ private applyHints( cards: WeightedCard[], hints: ReplanHints, allCards: WeightedCard[] ): WeightedCard[] { const beforeCount = cards.length; // 1. Exclude if (hints.excludeCards?.length) { cards = cards.filter( (c) => !hints.excludeCards!.some((pat) => globMatch(c.cardId, pat)) ); } if (hints.excludeTags?.length) { cards = cards.filter( (c) => !hints.excludeTags!.some((pat) => cardMatchesTagPattern(c, pat)) ); } // 2. Boost if (hints.boostTags) { for (const [pattern, factor] of Object.entries(hints.boostTags)) { for (const card of cards) { if (cardMatchesTagPattern(card, pattern)) { card.score *= factor; card.provenance.push({ strategy: 'ephemeralHint', strategyId: 'ephemeral-hint', strategyName: hints._label ? `Replan Hint (${hints._label})` : 'Replan Hint', action: 'boosted', score: card.score, reason: `boostTag ${pattern} ×${factor}`, }); } } } } if (hints.boostCards) { for (const [pattern, factor] of Object.entries(hints.boostCards)) { for (const card of cards) { if (globMatch(card.cardId, pattern)) { card.score *= factor; card.provenance.push({ strategy: 'ephemeralHint', strategyId: 'ephemeral-hint', strategyName: hints._label ? `Replan Hint (${hints._label})` : 'Replan Hint', action: 'boosted', score: card.score, reason: `boostCard ${pattern} ×${factor}`, }); } } } } // 3. Require — ensure mandatory cards have floor score and are in the pool const cardIds = new Set(cards.map((c) => c.cardId)); const cardMap = new Map(cards.map((c) => [c.cardId, c])); const hintLabel = hints._label ? `Replan Hint (${hints._label})` : 'Replan Hint'; const applyRequirement = (card: WeightedCard, reason: string) => { const mandatoryScore = Number.POSITIVE_INFINITY; const existing = cardMap.get(card.cardId); if (existing) { // If already in the pool, upgrade to mandatory score if not already infinite if (existing.score < mandatoryScore) { existing.score = mandatoryScore; existing.provenance.push({ strategy: 'ephemeralHint', strategyId: 'ephemeral-hint', strategyName: hintLabel, action: 'boosted', score: mandatoryScore, reason: `${reason} (upgrade to mandatory score)`, }); } } else { // If missing, inject from the full pool cards.push({ ...card, score: mandatoryScore, provenance: [ ...card.provenance, { strategy: 'ephemeralHint', strategyId: 'ephemeral-hint', strategyName: hintLabel, action: 'boosted', score: mandatoryScore, reason, }, ], }); cardIds.add(card.cardId); cardMap.set(card.cardId, cards[cards.length - 1]); } }; if (hints.requireCards?.length) { for (const pattern of hints.requireCards) { // First check candidates already in the pool for (const cardId of cardIds) { if (globMatch(cardId, pattern)) { applyRequirement(cardMap.get(cardId)!, `requireCard ${pattern}`); } } // Then check full pool for injection for (const card of allCards) { if (globMatch(card.cardId, pattern)) { applyRequirement(card, `requireCard ${pattern}`); } } } } if (hints.requireTags?.length) { for (const pattern of hints.requireTags) { // First check candidates already in the pool for (const cardId of cardIds) { const card = cardMap.get(cardId)!; if (cardMatchesTagPattern(card, pattern)) { applyRequirement(card, `requireTag ${pattern}`); } } // Then check full pool for injection for (const card of allCards) { if (cardMatchesTagPattern(card, pattern)) { applyRequirement(card, `requireTag ${pattern}`); } } } } logger.info(`[Pipeline] Hints applied: ${beforeCount} → ${cards.length} cards`); return cards; } /** * Build shared context for generator and filters. * * Called once per getWeightedCards() invocation. * Contains data that the generator and multiple filters might need. * * The context satisfies both GeneratorContext and FilterContext interfaces. */ private async buildContext(): Promise { let userElo = 1000; // Default ELO try { const courseReg = await this.user!.getCourseRegDoc(this.course!.getCourseID()); const courseElo = toCourseElo(courseReg.elo); userElo = courseElo.global.score; } catch (e) { logger.debug(`[Pipeline] Could not get user ELO, using default: ${e}`); } // Reuse cached orchestration context if available (course config is stable // within a page load). This avoids a remote getCourseConfig() call on // subsequent pipeline runs (e.g. mid-session replans). if (!this._cachedOrchestration) { this._cachedOrchestration = await createOrchestrationContext(this.user!, this.course!); } const orchestration = this._cachedOrchestration; return { user: this.user!, course: this.course!, userElo, orchestration, }; } /** * Get the course ID for this pipeline. */ getCourseID(): string { return this.course!.getCourseID(); } /** * Get orchestration context for outcome recording. */ async getOrchestrationContext(): Promise { return createOrchestrationContext(this.user!, this.course!); } /** * Get IDs of all strategies in this pipeline. * Used to record which strategies contributed to an outcome. */ getStrategyIds(): string[] { const ids: string[] = []; const extractId = (obj: any): string | null => { // Check for strategyId property (ContentNavigator, WeightedFilter) if (obj.strategyId) return obj.strategyId; return null; }; // Generator(s) const genId = extractId(this.generator); if (genId) ids.push(genId); // Inspect CompositeGenerator children (accessing private field via cast) if ((this.generator as any).generators && Array.isArray((this.generator as any).generators)) { (this.generator as any).generators.forEach((g: any) => { const subId = extractId(g); if (subId) ids.push(subId); }); } // Filters for (const filter of this.filters) { const fId = extractId(filter); if (fId) ids.push(fId); } return [...new Set(ids)]; } // --------------------------------------------------------------------------- // Tag ELO diagnostic // --------------------------------------------------------------------------- /** * Get the user's per-tag ELO data for specified tags (or all tags). * Useful for diagnosing why hierarchy gates are open/closed. */ async getTagEloStatus( tagFilter?: string | string[] ): Promise> { const courseReg = await this.user!.getCourseRegDoc(this.course!.getCourseID()); const courseElo = toCourseElo(courseReg.elo); const result: Record = {}; if (!tagFilter) { // Return all tags for (const [tag, data] of Object.entries(courseElo.tags)) { result[tag] = { score: data.score, count: data.count }; } } else { const patterns = Array.isArray(tagFilter) ? tagFilter : [tagFilter]; for (const pattern of patterns) { const regex = globToRegex(pattern); for (const [tag, data] of Object.entries(courseElo.tags)) { if (regex.test(tag)) { result[tag] = { score: data.score, count: data.count }; } } } } return result; } // --------------------------------------------------------------------------- // Card-space diagnostic // --------------------------------------------------------------------------- /** * Commit-free forecast: score the user's full card space through the filter * chain and return the cards that are currently *reachable* (score >= * threshold), optionally nudged by caller-supplied hints and/or restricted * to cards the user hasn't seen yet. * * This is a GENERIC primitive — it returns scored, tag-hydrated cards and * stops there. It has no knowledge of any particular tag convention; callers * decide what the surviving cards mean (e.g. filter to their own "intro" * tag family). Nothing is written and no session is started. * * The optional `hints` are the "out-of-band kick": they run through the same * {@link applyHints} path a live replan uses, so the two semantics carry over — * - `boostTags`/`boostCards` reweight *within* gating (a gated score-0 card * stays out), and * - `requireTags`/`requireCards` inject from the full pre-filter pool, * *bypassing* gating (use when you want a card regardless of reachability). * Note `unseenOnly` is applied LAST, so it can drop a `require`d card that the * user has already seen — pass `unseenOnly: false` if that matters. * * Cost note: like {@link diagnoseCardSpace}, this scans every card through the * filters, so it's heavier than a normal replan. Intended for one-shot * out-of-band use (e.g. a session-end "what's next" snapshot), not the hot path. * * @param opts.hints Optional ephemeral hints to apply after the filter chain. * @param opts.unseenOnly Only return cards the user hasn't encountered (default true). * @param opts.threshold Min score to count as reachable (default 0.10). * @param opts.limit Optional cap on results (already sorted desc). */ async forecast(opts?: { hints?: ReplanHints; unseenOnly?: boolean; threshold?: number; limit?: number; }): Promise { const threshold = opts?.threshold ?? 0.10; const unseenOnly = opts?.unseenOnly ?? true; const courseId = this.course!.getCourseID(); const allCardIds = await this.course!.getAllCardIds(); let cards: WeightedCard[] = allCardIds.map((cardId) => ({ cardId, courseId, score: 1.0, provenance: [], })); cards = await this.hydrateTags(cards); // Snapshot the full pool before filtering, for require-injection in applyHints. const fullPool = cards.slice(); const context = await this.buildContext(); for (const filter of this.filters) { cards = await filter.transform(cards, context); } if (opts?.hints) { cards = this.applyHints(cards, opts.hints, fullPool); } cards = cards.filter((c) => c.score >= threshold); if (unseenOnly) { let encountered: Set; try { encountered = new Set(await this.user!.getSeenCards(courseId)); } catch { encountered = new Set(); } cards = cards.filter((c) => !encountered.has(c.cardId)); } cards.sort((a, b) => b.score - a.score); return opts?.limit ? cards.slice(0, opts.limit) : cards; } /** * Scan every card in the course through the filter chain and report * how many are "well indicated" (score >= threshold) for the current user. * * Also reports how many well-indicated cards the user has NOT yet encountered. * * Exposed via `window.skuilder.pipeline.diagnoseCardSpace()`. */ async diagnoseCardSpace(opts?: { threshold?: number }): Promise { const THRESHOLD = opts?.threshold ?? 0.10; const t0 = performance.now(); // 1. Get all card IDs const allCardIds = await this.course!.getAllCardIds(); // 2. Build dummy WeightedCards (score=1.0, no provenance) let cards: WeightedCard[] = allCardIds.map((cardId) => ({ cardId, courseId: this.course!.getCourseID(), score: 1.0, provenance: [], })); // 3. Hydrate tags cards = await this.hydrateTags(cards); // 4. Run through filters const context = await this.buildContext(); const filterBreakdown: Array<{ name: string; wellIndicated: number }> = []; // Track cumulative filter effects for (const filter of this.filters) { cards = await filter.transform(cards, context); const wi = cards.filter((c) => c.score >= THRESHOLD).length; filterBreakdown.push({ name: filter.name, wellIndicated: wi }); } // 5. Count well-indicated const wellIndicated = cards.filter((c) => c.score >= THRESHOLD); const wellIndicatedIds = new Set(wellIndicated.map((c) => c.cardId)); // 6. Get encountered cards let encounteredIds: Set; try { const courseId = this.course!.getCourseID(); const seenCards = await this.user!.getSeenCards(courseId); encounteredIds = new Set(seenCards); } catch { encounteredIds = new Set(); } const wellIndicatedNew = wellIndicated.filter((c) => !encounteredIds.has(c.cardId)); // 7. Group by card type const byType = new Map(); for (const card of cards) { const type = card.cardId.split('-')[1] || 'unknown'; // c-ws-... → ws, c-intro-... → intro, etc. if (!byType.has(type)) { byType.set(type, { total: 0, wellIndicated: 0, new: 0 }); } const entry = byType.get(type)!; entry.total++; if (card.score >= THRESHOLD) { entry.wellIndicated++; if (!encounteredIds.has(card.cardId)) entry.new++; } } const elapsed = performance.now() - t0; const result: CardSpaceDiagnosis = { totalCards: allCardIds.length, threshold: THRESHOLD, wellIndicated: wellIndicatedIds.size, encountered: encounteredIds.size, wellIndicatedNew: wellIndicatedNew.length, byType: Object.fromEntries(byType), filterBreakdown, elapsedMs: Math.round(elapsed), }; // Log to console logger.info(`[Pipeline:diagnose] Card space scan (${result.elapsedMs}ms):`); logger.info(`[Pipeline:diagnose] Total cards: ${result.totalCards}`); logger.info(`[Pipeline:diagnose] Well-indicated (score >= ${THRESHOLD}): ${result.wellIndicated}`); logger.info(`[Pipeline:diagnose] Encountered: ${result.encountered}`); logger.info(`[Pipeline:diagnose] Well-indicated & new: ${result.wellIndicatedNew}`); logger.info(`[Pipeline:diagnose] By type:`); for (const [type, counts] of byType) { logger.info( `[Pipeline:diagnose] ${type}: ${counts.wellIndicated}/${counts.total} well-indicated, ${counts.new} new` ); } logger.info(`[Pipeline:diagnose] After each filter:`); for (const fb of filterBreakdown) { logger.info(`[Pipeline:diagnose] ${fb.name}: ${fb.wellIndicated} well-indicated`); } return result; } } /** * Diagnosis of the full card space for the current user. */ export interface CardSpaceDiagnosis { totalCards: number; threshold: number; wellIndicated: number; encountered: number; wellIndicatedNew: number; byType: Record; filterBreakdown: Array<{ name: string; wellIndicated: number }>; elapsedMs: number; }