@vue-skuilder/db 0.1.23 → 0.1.25

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.
Files changed (80) hide show
  1. package/dist/{contentSource-BP9hznNV.d.ts → contentSource-BmnmvH8C.d.ts} +268 -3
  2. package/dist/{contentSource-DsJadoBU.d.cts → contentSource-DfBbaLA-.d.cts} +268 -3
  3. package/dist/core/index.d.cts +310 -6
  4. package/dist/core/index.d.ts +310 -6
  5. package/dist/core/index.js +2606 -666
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +2564 -639
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-CHYrQ5pB.d.cts → dataLayerProvider-BeRXVMs5.d.cts} +1 -1
  10. package/dist/{dataLayerProvider-MDTxXq2l.d.ts → dataLayerProvider-CG9GfaAY.d.ts} +1 -1
  11. package/dist/impl/couch/index.d.cts +11 -3
  12. package/dist/impl/couch/index.d.ts +11 -3
  13. package/dist/impl/couch/index.js +2336 -656
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +2316 -631
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.cts +4 -4
  18. package/dist/impl/static/index.d.ts +4 -4
  19. package/dist/impl/static/index.js +2312 -632
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +2315 -630
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/{index-Dj0SEgk3.d.ts → index-BWvO-_rJ.d.ts} +1 -1
  24. package/dist/{index-B_j6u5E4.d.cts → index-Ba7hYbHj.d.cts} +1 -1
  25. package/dist/index.d.cts +278 -20
  26. package/dist/index.d.ts +278 -20
  27. package/dist/index.js +3603 -720
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +3529 -674
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/{types-DQaXnuoc.d.ts → types-CJrLM1Ew.d.ts} +1 -1
  32. package/dist/{types-Bn0itutr.d.cts → types-W8n-B6HG.d.cts} +1 -1
  33. package/dist/{types-legacy-DDY4N-Uq.d.cts → types-legacy-JXDxinpU.d.cts} +5 -1
  34. package/dist/{types-legacy-DDY4N-Uq.d.ts → types-legacy-JXDxinpU.d.ts} +5 -1
  35. package/dist/util/packer/index.d.cts +3 -3
  36. package/dist/util/packer/index.d.ts +3 -3
  37. package/docs/brainstorm-navigation-paradigm.md +40 -34
  38. package/docs/future-orchestration-vision.md +216 -0
  39. package/docs/navigators-architecture.md +210 -9
  40. package/docs/todo-review-urgency-adaptation.md +205 -0
  41. package/docs/todo-strategy-authoring.md +8 -6
  42. package/package.json +3 -3
  43. package/src/core/index.ts +2 -0
  44. package/src/core/interfaces/contentSource.ts +7 -0
  45. package/src/core/interfaces/userDB.ts +50 -0
  46. package/src/core/navigators/Pipeline.ts +132 -5
  47. package/src/core/navigators/PipelineAssembler.ts +21 -22
  48. package/src/core/navigators/PipelineDebugger.ts +426 -0
  49. package/src/core/navigators/filters/WeightedFilter.ts +141 -0
  50. package/src/core/navigators/filters/types.ts +4 -0
  51. package/src/core/navigators/generators/CompositeGenerator.ts +82 -19
  52. package/src/core/navigators/generators/elo.ts +14 -1
  53. package/src/core/navigators/generators/srs.ts +146 -18
  54. package/src/core/navigators/generators/types.ts +4 -0
  55. package/src/core/navigators/index.ts +203 -13
  56. package/src/core/orchestration/gradient.ts +133 -0
  57. package/src/core/orchestration/index.ts +210 -0
  58. package/src/core/orchestration/learning.ts +250 -0
  59. package/src/core/orchestration/recording.ts +92 -0
  60. package/src/core/orchestration/signal.ts +67 -0
  61. package/src/core/types/contentNavigationStrategy.ts +38 -0
  62. package/src/core/types/learningState.ts +77 -0
  63. package/src/core/types/types-legacy.ts +4 -0
  64. package/src/core/types/userOutcome.ts +51 -0
  65. package/src/courseConfigRegistration.ts +107 -0
  66. package/src/factory.ts +6 -0
  67. package/src/impl/common/BaseUserDB.ts +16 -0
  68. package/src/impl/couch/user-course-relDB.ts +12 -0
  69. package/src/study/MixerDebugger.ts +555 -0
  70. package/src/study/SessionController.ts +159 -20
  71. package/src/study/SessionDebugger.ts +442 -0
  72. package/src/study/SourceMixer.ts +36 -17
  73. package/src/study/TODO-session-scheduling.md +133 -0
  74. package/src/study/index.ts +2 -0
  75. package/src/study/services/EloService.ts +79 -4
  76. package/src/study/services/ResponseProcessor.ts +130 -72
  77. package/src/study/services/SrsService.ts +9 -0
  78. package/tests/core/navigators/Pipeline.test.ts +2 -0
  79. package/tests/core/navigators/PipelineAssembler.test.ts +4 -4
  80. package/docs/todo-evolutionary-orchestration.md +0 -310
@@ -0,0 +1,426 @@
1
+ import type { WeightedCard, StrategyContribution } from './index';
2
+ import { logger } from '../../util/logger';
3
+
4
+ // ============================================================================
5
+ // PIPELINE DEBUGGER
6
+ // ============================================================================
7
+ //
8
+ // Console-accessible debug API for inspecting pipeline decisions.
9
+ //
10
+ // Exposed as `window.skuilder.pipeline` for interactive exploration.
11
+ //
12
+ // Usage:
13
+ // window.skuilder.pipeline.showLastRun()
14
+ // window.skuilder.pipeline.showCard('cardId123')
15
+ // window.skuilder.pipeline.explainReviews()
16
+ // window.skuilder.pipeline.export()
17
+ //
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Summary of a single generator's contribution.
22
+ */
23
+ export interface GeneratorSummary {
24
+ name: string;
25
+ cardCount: number;
26
+ newCount: number;
27
+ reviewCount: number;
28
+ topScore: number;
29
+ }
30
+
31
+ /**
32
+ * Summary of a filter's impact on scores.
33
+ */
34
+ export interface FilterImpact {
35
+ name: string;
36
+ boosted: number;
37
+ penalized: number;
38
+ passed: number;
39
+ removed: number;
40
+ }
41
+
42
+ /**
43
+ * Complete record of a single pipeline execution.
44
+ */
45
+ export interface PipelineRunReport {
46
+ runId: string;
47
+ timestamp: Date;
48
+ courseId: string;
49
+ courseName?: string;
50
+
51
+ // Generator phase
52
+ generatorName: string;
53
+ generators?: GeneratorSummary[];
54
+ generatedCount: number;
55
+
56
+ // Filter phase
57
+ filters: FilterImpact[];
58
+
59
+ // Results
60
+ finalCount: number;
61
+ reviewsSelected: number;
62
+ newSelected: number;
63
+
64
+ // Full card data for inspection
65
+ cards: Array<{
66
+ cardId: string;
67
+ courseId: string;
68
+ origin: 'new' | 'review' | 'unknown';
69
+ finalScore: number;
70
+ provenance: StrategyContribution[];
71
+ selected: boolean;
72
+ }>;
73
+ }
74
+
75
+ /**
76
+ * Ring buffer for storing recent pipeline runs.
77
+ */
78
+ const MAX_RUNS = 10;
79
+ const runHistory: PipelineRunReport[] = [];
80
+
81
+ /**
82
+ * Determine card origin from provenance trail.
83
+ */
84
+ function getOrigin(card: WeightedCard): 'new' | 'review' | 'unknown' {
85
+ const firstEntry = card.provenance[0];
86
+ if (!firstEntry) return 'unknown';
87
+ const reason = firstEntry.reason?.toLowerCase() || '';
88
+ if (reason.includes('new card')) return 'new';
89
+ if (reason.includes('review')) return 'review';
90
+ return 'unknown';
91
+ }
92
+
93
+ /**
94
+ * Capture a pipeline run for later inspection.
95
+ */
96
+ export function captureRun(report: Omit<PipelineRunReport, 'runId' | 'timestamp'>): void {
97
+ const fullReport: PipelineRunReport = {
98
+ ...report,
99
+ runId: `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
100
+ timestamp: new Date(),
101
+ };
102
+
103
+ runHistory.unshift(fullReport);
104
+ if (runHistory.length > MAX_RUNS) {
105
+ runHistory.pop();
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Build a capture-ready report from pipeline execution data.
111
+ */
112
+ export function buildRunReport(
113
+ courseId: string,
114
+ courseName: string | undefined,
115
+ generatorName: string,
116
+ generators: GeneratorSummary[] | undefined,
117
+ generatedCount: number,
118
+ filters: FilterImpact[],
119
+ allCards: WeightedCard[],
120
+ selectedCards: WeightedCard[]
121
+ ): Omit<PipelineRunReport, 'runId' | 'timestamp'> {
122
+ const selectedIds = new Set(selectedCards.map((c) => c.cardId));
123
+
124
+ const cards = allCards.map((card) => ({
125
+ cardId: card.cardId,
126
+ courseId: card.courseId,
127
+ origin: getOrigin(card),
128
+ finalScore: card.score,
129
+ provenance: card.provenance,
130
+ selected: selectedIds.has(card.cardId),
131
+ }));
132
+
133
+ const reviewsSelected = selectedCards.filter((c) => getOrigin(c) === 'review').length;
134
+ const newSelected = selectedCards.filter((c) => getOrigin(c) === 'new').length;
135
+
136
+ return {
137
+ courseId,
138
+ courseName,
139
+ generatorName,
140
+ generators,
141
+ generatedCount,
142
+ filters,
143
+ finalCount: selectedCards.length,
144
+ reviewsSelected,
145
+ newSelected,
146
+ cards,
147
+ };
148
+ }
149
+
150
+ // ============================================================================
151
+ // CONSOLE API
152
+ // ============================================================================
153
+
154
+ /**
155
+ * Format a provenance trail for console display.
156
+ */
157
+ function formatProvenance(provenance: StrategyContribution[]): string {
158
+ return provenance
159
+ .map((p) => {
160
+ const actionSymbol =
161
+ p.action === 'generated'
162
+ ? '🎲'
163
+ : p.action === 'boosted'
164
+ ? '⬆️'
165
+ : p.action === 'penalized'
166
+ ? '⬇️'
167
+ : '➡️';
168
+ return ` ${actionSymbol} ${p.strategyName}: ${p.score.toFixed(3)} - ${p.reason}`;
169
+ })
170
+ .join('\n');
171
+ }
172
+
173
+ /**
174
+ * Print summary of a single pipeline run.
175
+ */
176
+ function printRunSummary(run: PipelineRunReport): void {
177
+ // eslint-disable-next-line no-console
178
+ console.group(`🔍 Pipeline Run: ${run.courseId} (${run.courseName || 'unnamed'})`);
179
+ logger.info(`Run ID: ${run.runId}`);
180
+ logger.info(`Time: ${run.timestamp.toISOString()}`);
181
+ logger.info(`Generator: ${run.generatorName} → ${run.generatedCount} candidates`);
182
+
183
+ if (run.generators && run.generators.length > 0) {
184
+ // eslint-disable-next-line no-console
185
+ console.group('Generator breakdown:');
186
+ for (const g of run.generators) {
187
+ logger.info(
188
+ ` ${g.name}: ${g.cardCount} cards (${g.newCount} new, ${g.reviewCount} reviews, top: ${g.topScore.toFixed(2)})`
189
+ );
190
+ }
191
+ // eslint-disable-next-line no-console
192
+ console.groupEnd();
193
+ }
194
+
195
+ if (run.filters.length > 0) {
196
+ // eslint-disable-next-line no-console
197
+ console.group('Filter impact:');
198
+ for (const f of run.filters) {
199
+ logger.info(` ${f.name}: ↑${f.boosted} ↓${f.penalized} =${f.passed} ✕${f.removed}`);
200
+ }
201
+ // eslint-disable-next-line no-console
202
+ console.groupEnd();
203
+ }
204
+
205
+ logger.info(
206
+ `Result: ${run.finalCount} cards selected (${run.newSelected} new, ${run.reviewsSelected} reviews)`
207
+ );
208
+ // eslint-disable-next-line no-console
209
+ console.groupEnd();
210
+ }
211
+
212
+ /**
213
+ * Console API object exposed on window.skuilder.pipeline
214
+ */
215
+ export const pipelineDebugAPI = {
216
+ /**
217
+ * Get raw run history for programmatic access.
218
+ */
219
+ get runs(): PipelineRunReport[] {
220
+ return [...runHistory];
221
+ },
222
+
223
+ /**
224
+ * Show summary of a specific pipeline run.
225
+ */
226
+ showRun(idOrIndex: string | number = 0): void {
227
+ if (runHistory.length === 0) {
228
+ logger.info('[Pipeline Debug] No runs captured yet.');
229
+ return;
230
+ }
231
+
232
+ let run: PipelineRunReport | undefined;
233
+
234
+ if (typeof idOrIndex === 'number') {
235
+ run = runHistory[idOrIndex];
236
+ if (!run) {
237
+ logger.info(
238
+ `[Pipeline Debug] No run found at index ${idOrIndex}. History length: ${runHistory.length}`
239
+ );
240
+ return;
241
+ }
242
+ } else {
243
+ run = runHistory.find((r) => r.runId.endsWith(idOrIndex));
244
+ if (!run) {
245
+ logger.info(`[Pipeline Debug] No run found matching ID '${idOrIndex}'.`);
246
+ return;
247
+ }
248
+ }
249
+
250
+ printRunSummary(run);
251
+ },
252
+
253
+ /**
254
+ * Show summary of the last pipeline run.
255
+ */
256
+ showLastRun(): void {
257
+ this.showRun(0);
258
+ },
259
+
260
+ /**
261
+ * Show detailed provenance for a specific card.
262
+ */
263
+ showCard(cardId: string): void {
264
+ for (const run of runHistory) {
265
+ const card = run.cards.find((c) => c.cardId === cardId);
266
+ if (card) {
267
+ // eslint-disable-next-line no-console
268
+ console.group(`🎴 Card: ${cardId}`);
269
+ logger.info(`Course: ${card.courseId}`);
270
+ logger.info(`Origin: ${card.origin}`);
271
+ logger.info(`Final score: ${card.finalScore.toFixed(3)}`);
272
+ logger.info(`Selected: ${card.selected ? 'Yes ✅' : 'No ❌'}`);
273
+ logger.info('Provenance:');
274
+ logger.info(formatProvenance(card.provenance));
275
+ // eslint-disable-next-line no-console
276
+ console.groupEnd();
277
+ return;
278
+ }
279
+ }
280
+ logger.info(`[Pipeline Debug] Card '${cardId}' not found in recent runs.`);
281
+ },
282
+
283
+ /**
284
+ * Explain why reviews may or may not have been selected.
285
+ */
286
+ explainReviews(): void {
287
+ if (runHistory.length === 0) {
288
+ logger.info('[Pipeline Debug] No runs captured yet.');
289
+ return;
290
+ }
291
+
292
+ // eslint-disable-next-line no-console
293
+ console.group('📋 Review Selection Analysis');
294
+
295
+ for (const run of runHistory) {
296
+ // eslint-disable-next-line no-console
297
+ console.group(`Run: ${run.courseId} @ ${run.timestamp.toLocaleTimeString()}`);
298
+
299
+ const allReviews = run.cards.filter((c) => c.origin === 'review');
300
+ const selectedReviews = allReviews.filter((c) => c.selected);
301
+
302
+ if (allReviews.length === 0) {
303
+ logger.info('❌ No reviews were generated. Check SRS logs for why.');
304
+ } else if (selectedReviews.length === 0) {
305
+ logger.info(`⚠️ ${allReviews.length} reviews generated but none selected.`);
306
+ logger.info('Possible reasons:');
307
+
308
+ // Check if new cards scored higher
309
+ const topNewScore = Math.max(
310
+ ...run.cards.filter((c) => c.origin === 'new' && c.selected).map((c) => c.finalScore),
311
+ 0
312
+ );
313
+ const topReviewScore = Math.max(...allReviews.map((c) => c.finalScore), 0);
314
+
315
+ if (topReviewScore < topNewScore) {
316
+ logger.info(
317
+ ` - New cards scored higher (top new: ${topNewScore.toFixed(2)}, top review: ${topReviewScore.toFixed(2)})`
318
+ );
319
+ }
320
+
321
+ // Show top review that didn't make it
322
+ const topReview = allReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
323
+ if (topReview) {
324
+ logger.info(` - Top review score: ${topReview.finalScore.toFixed(3)}`);
325
+ logger.info(' - Its provenance:');
326
+ logger.info(formatProvenance(topReview.provenance));
327
+ }
328
+ } else {
329
+ logger.info(`✅ ${selectedReviews.length}/${allReviews.length} reviews selected.`);
330
+ logger.info('Top selected review:');
331
+ const topSelected = selectedReviews.sort((a, b) => b.finalScore - a.finalScore)[0];
332
+ logger.info(formatProvenance(topSelected.provenance));
333
+ }
334
+
335
+ // eslint-disable-next-line no-console
336
+ console.groupEnd();
337
+ }
338
+
339
+ // eslint-disable-next-line no-console
340
+ console.groupEnd();
341
+ },
342
+
343
+ /**
344
+ * Show all runs in compact format.
345
+ */
346
+ listRuns(): void {
347
+ if (runHistory.length === 0) {
348
+ logger.info('[Pipeline Debug] No runs captured yet.');
349
+ return;
350
+ }
351
+
352
+ // eslint-disable-next-line no-console
353
+ console.table(
354
+ runHistory.map((r) => ({
355
+ id: r.runId.slice(-8),
356
+ time: r.timestamp.toLocaleTimeString(),
357
+ course: r.courseName || r.courseId.slice(0, 8),
358
+ generated: r.generatedCount,
359
+ selected: r.finalCount,
360
+ new: r.newSelected,
361
+ reviews: r.reviewsSelected,
362
+ }))
363
+ );
364
+ },
365
+
366
+ /**
367
+ * Export run history as JSON for bug reports.
368
+ */
369
+ export(): string {
370
+ const json = JSON.stringify(runHistory, null, 2);
371
+ logger.info('[Pipeline Debug] Run history exported. Copy the returned string or use:');
372
+ logger.info(' copy(window.skuilder.pipeline.export())');
373
+ return json;
374
+ },
375
+
376
+ /**
377
+ * Clear run history.
378
+ */
379
+ clear(): void {
380
+ runHistory.length = 0;
381
+ logger.info('[Pipeline Debug] Run history cleared.');
382
+ },
383
+
384
+ /**
385
+ * Show help.
386
+ */
387
+ help(): void {
388
+ logger.info(`
389
+ 🔧 Pipeline Debug API
390
+
391
+ Commands:
392
+ .showLastRun() Show summary of most recent pipeline run
393
+ .showRun(id|index) Show summary of a specific run (by index or ID suffix)
394
+ .showCard(cardId) Show provenance trail for a specific card
395
+ .explainReviews() Analyze why reviews were/weren't selected
396
+ .listRuns() List all captured runs in table format
397
+ .export() Export run history as JSON for bug reports
398
+ .clear() Clear run history
399
+ .runs Access raw run history array
400
+ .help() Show this help message
401
+
402
+ Example:
403
+ window.skuilder.pipeline.showLastRun()
404
+ window.skuilder.pipeline.showRun(1)
405
+ window.skuilder.pipeline.showCard('abc123')
406
+ `);
407
+ },
408
+ };
409
+
410
+ // ============================================================================
411
+ // WINDOW MOUNT
412
+ // ============================================================================
413
+
414
+ /**
415
+ * Mount the debug API on window.skuilder.pipeline
416
+ */
417
+ export function mountPipelineDebugger(): void {
418
+ if (typeof window === 'undefined') return;
419
+
420
+ const win = window as any;
421
+ win.skuilder = win.skuilder || {};
422
+ win.skuilder.pipeline = pipelineDebugAPI;
423
+ }
424
+
425
+ // Auto-mount when module is loaded
426
+ mountPipelineDebugger();
@@ -0,0 +1,141 @@
1
+ import { CardFilter, FilterContext } from './types';
2
+ import { WeightedCard } from '../index';
3
+ import { LearnableWeight, DEFAULT_LEARNABLE_WEIGHT } from '../../types/contentNavigationStrategy';
4
+
5
+ /**
6
+ * Wraps a CardFilter to apply evolutionary weighting to its effects.
7
+ *
8
+ * If a filter applies a multiplier M (score * M), and the strategy has
9
+ * an effective weight W, the final multiplier becomes M^W.
10
+ *
11
+ * - W=1.0: Original behavior
12
+ * - W>1.0: Amplifies the filter's opinion (stronger boost/penalty)
13
+ * - W<1.0: Dampens the filter's opinion (weaker boost/penalty)
14
+ * - W=0.0: Nullifies the filter (identity)
15
+ *
16
+ * This wrapper handles the math of scaling the filter's impact and updating
17
+ * the provenance trail with the effective weight used.
18
+ */
19
+ export class WeightedFilter implements CardFilter {
20
+ public name: string;
21
+ private inner: CardFilter;
22
+ private learnable: LearnableWeight;
23
+ private staticWeight: boolean;
24
+ private strategyId?: string;
25
+
26
+ constructor(
27
+ inner: CardFilter,
28
+ learnable: LearnableWeight = DEFAULT_LEARNABLE_WEIGHT,
29
+ staticWeight: boolean = false,
30
+ strategyId?: string
31
+ ) {
32
+ this.inner = inner;
33
+ this.name = inner.name;
34
+ this.learnable = learnable;
35
+ this.staticWeight = staticWeight;
36
+ this.strategyId = strategyId;
37
+ }
38
+
39
+ /**
40
+ * Apply the inner filter, then scale its effect by the configured weight.
41
+ */
42
+ async transform(cards: WeightedCard[], context: FilterContext): Promise<WeightedCard[]> {
43
+ // ========================================================================
44
+ // 1. DETERMINE EFFECTIVE WEIGHT
45
+ // ========================================================================
46
+
47
+ // Determine effective weight using orchestration context if available
48
+ let effectiveWeight = this.learnable.weight;
49
+ let deviation: number | undefined;
50
+
51
+ if (!this.staticWeight && context.orchestration) {
52
+ // ContentNavigator instances have a strategyId property (protected/private)
53
+ // We assume inner filter is a ContentNavigator or has a strategyId property.
54
+ // Fallback to name if not present.
55
+ const strategyId = this.strategyId || (this.inner as any).strategyId || this.name;
56
+ effectiveWeight = context.orchestration.getEffectiveWeight(strategyId, this.learnable);
57
+ deviation = context.orchestration.getDeviation(strategyId);
58
+ }
59
+
60
+ // Optimization: If weight is 1.0, the scaling is an identity operation.
61
+ // Just run the inner filter directly.
62
+ if (Math.abs(effectiveWeight - 1.0) < 0.001) {
63
+ return this.inner.transform(cards, context);
64
+ }
65
+
66
+ // ========================================================================
67
+ // 2. CAPTURE STATE BEFORE FILTER
68
+ // ========================================================================
69
+
70
+ // We need original scores to calculate what the filter did (M = new/old)
71
+ const originalScores = new Map<string, number>();
72
+ for (const card of cards) {
73
+ originalScores.set(card.cardId, card.score);
74
+ }
75
+
76
+ // ========================================================================
77
+ // 3. RUN INNER FILTER
78
+ // ========================================================================
79
+
80
+ const transformedCards = await this.inner.transform(cards, context);
81
+
82
+ // ========================================================================
83
+ // 4. APPLY WEIGHT SCALING
84
+ // ========================================================================
85
+
86
+ return transformedCards.map((card) => {
87
+ const originalScore = originalScores.get(card.cardId);
88
+
89
+ // Edge cases where we can't or shouldn't scale:
90
+ // - Original score missing (shouldn't happen)
91
+ // - Original score 0 (was already excluded)
92
+ // - New score 0 (filter excluded it / vetoed) - we treat vetoes as absolute
93
+ if (originalScore === undefined || originalScore === 0 || card.score === 0) {
94
+ return card;
95
+ }
96
+
97
+ // Calculate raw effect multiplier: M = new / old
98
+ const rawEffect = card.score / originalScore;
99
+
100
+ // If filter didn't change this card, nothing to scale
101
+ if (Math.abs(rawEffect - 1.0) < 0.0001) {
102
+ return card;
103
+ }
104
+
105
+ // Apply weight: scaled = M ^ W
106
+ // Example: 0.5 penalty ^ 2.0 weight = 0.25 (stronger penalty)
107
+ // Example: 0.5 penalty ^ 0.5 weight = 0.707 (weaker penalty)
108
+ const weightedEffect = Math.pow(rawEffect, effectiveWeight);
109
+ const newScore = originalScore * weightedEffect;
110
+
111
+ // Update provenance
112
+ // The inner filter just added the last entry. We need to update it
113
+ // to reflect the weighted score and record the effective weight.
114
+ const lastProvIndex = card.provenance.length - 1;
115
+ const lastProv = card.provenance[lastProvIndex];
116
+
117
+ if (lastProv) {
118
+ const updatedProvenance = [...card.provenance];
119
+ updatedProvenance[lastProvIndex] = {
120
+ ...lastProv,
121
+ score: newScore,
122
+ effectiveWeight: effectiveWeight,
123
+ deviation: deviation,
124
+ // We can optionally append to the reason, but the structured field is key
125
+ };
126
+
127
+ return {
128
+ ...card,
129
+ score: newScore,
130
+ provenance: updatedProvenance,
131
+ };
132
+ }
133
+
134
+ // Fallback if no provenance found (rare)
135
+ return {
136
+ ...card,
137
+ score: newScore,
138
+ };
139
+ });
140
+ }
141
+ }
@@ -1,6 +1,7 @@
1
1
  import type { WeightedCard } from '../index';
2
2
  import type { CourseDBInterface } from '../../interfaces/courseDB';
3
3
  import type { UserDBInterface } from '../../interfaces/userDB';
4
+ import type { OrchestrationContext } from '../../orchestration';
4
5
 
5
6
  // ============================================================================
6
7
  // CARD FILTER INTERFACE
@@ -40,6 +41,9 @@ export interface FilterContext {
40
41
  /** User's global ELO score for this course */
41
42
  userElo: number;
42
43
 
44
+ /** Orchestration context for evolutionary weighting */
45
+ orchestration?: OrchestrationContext;
46
+
43
47
  // Future extensions:
44
48
  // - hydrated tags for all cards (batch lookup)
45
49
  // - user's tag-level ELO data