From 42acd7327ef3f1befc5ed08600d8f78715b1968e Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:09:03 +0700 Subject: [PATCH 01/16] feat(study): contracts for v1 study system (parallel-team scaffold) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishes the type definitions and module signatures all parallel tracks develop against: - src/lib/quiz/study-types.ts — Confidence, ResponseRecord, ScheduleEntry, StudySettings, StudyStore interface, storage key constants, capacity guard - src/lib/quiz/scheduler.ts — qualityOf() (impl) + applySM2() (T2 stub) - src/lib/quiz/selector.ts — selectQuestionsV2() (T2 stub) - src/lib/quiz/interleaver.ts — interleaveNearMisses() (T2 stub) - src/lib/quiz/store/{index,local}.ts — LocalStudyStore class shell (T1 stub) - src/content/config.ts — questionsCollection Zod schema (full) - src/lib/quiz/index.ts — re-exports Stubs throw with explicit "deferred to track Tn" messages so missing wiring is loud rather than silent. Tracks T1 (foundation), T2 (algorithms), T3 (UI), T4 (schema/tooling), T5 (editorial), T6 (docs) all branch from this commit. Refs V1-PLAN.md §3, §4.1, §5, §13.5. --- src/content/config.ts | 84 +++++++++++++++++++ src/lib/quiz/index.ts | 31 +++++++ src/lib/quiz/interleaver.ts | 22 +++++ src/lib/quiz/scheduler.ts | 50 +++++++++++ src/lib/quiz/selector.ts | 32 +++++++ src/lib/quiz/store/index.ts | 14 ++++ src/lib/quiz/store/local.ts | 74 ++++++++++++++++ src/lib/quiz/study-types.ts | 162 ++++++++++++++++++++++++++++++++++++ 8 files changed, 469 insertions(+) create mode 100644 src/lib/quiz/interleaver.ts create mode 100644 src/lib/quiz/scheduler.ts create mode 100644 src/lib/quiz/selector.ts create mode 100644 src/lib/quiz/store/index.ts create mode 100644 src/lib/quiz/store/local.ts create mode 100644 src/lib/quiz/study-types.ts diff --git a/src/content/config.ts b/src/content/config.ts index d97aa33..4c8130e 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -68,6 +68,89 @@ const sectionSchema = z.object({ subsections: z.array(subsectionSchema).optional(), }); +// ---------------------------------------------------------------------- +// Questions collection (study v1) +// ---------------------------------------------------------------------- +// See V1-PLAN.md §4.1. Track T4 owns expansions to this schema; other tracks +// must not alter it without orchestrator coordination. + +const questionOptionSchema = z.object({ + text: z.string().min(1), + isCorrect: z.boolean(), + rationale: z.string().optional(), +}); + +const questionsCollection = defineCollection({ + type: 'data', + schema: z + .object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + stem: z.string().min(10), + type: z.enum(['mcq', 'true-false', 'fill-blank', 'matching', 'image-mcq']), + + // mcq / true-false / image-mcq + options: z.array(questionOptionSchema).min(2).max(5).optional(), + + // fill-blank + acceptedAnswers: z.array(z.string()).optional(), + + // matching + pairs: z + .array(z.object({ left: z.string(), right: z.string() })) + .optional(), + + // universal + rationaleCorrect: z.string(), + difficulty: z.number().int().min(1).max(5), + category: z.enum([ + 'fundamentals', + 'equipment', + 'species', + 'identification', + 'phenomena', + 'origin', + 'market', + 'care', + ]), + conceptTags: z.array(z.string()), + sourceArticle: z.string().optional(), + examRelevance: z + .enum(['FGA-foundation', 'FGA-diploma', 'GIA-GG']) + .optional(), + confusionPairs: z.array(z.string()).optional(), + similarTo: z.array(z.string()).optional(), + imageRef: z.string().optional(), + unvetted: z.boolean().default(false), + authorReviewed: z.string().optional(), + lastReviewed: z.string().datetime().optional(), + deprecated: z.boolean().default(false), + references: z + .array( + z.object({ + id: z.string(), + citation: z.string(), + url: z.string().url().optional(), + }), + ) + .optional(), + }) + .refine( + (q) => { + if (q.type === 'mcq' || q.type === 'true-false' || q.type === 'image-mcq') { + return Boolean(q.options && q.options.some((o) => o.isCorrect)); + } + if (q.type === 'fill-blank') { + return Boolean(q.acceptedAnswers && q.acceptedAnswers.length > 0); + } + if (q.type === 'matching') { + return Boolean(q.pairs && q.pairs.length >= 2); + } + return false; + }, + { message: 'Question must have valid answer data for its type' }, + ), +}); + // Learn collection schema const learnCollection = defineCollection({ type: 'data', @@ -99,4 +182,5 @@ const learnCollection = defineCollection({ export const collections = { learn: learnCollection, + questions: questionsCollection, }; diff --git a/src/lib/quiz/index.ts b/src/lib/quiz/index.ts index 4589a61..a980e1a 100644 --- a/src/lib/quiz/index.ts +++ b/src/lib/quiz/index.ts @@ -66,3 +66,34 @@ export { shuffleWithCorrectIndex, generateWrongAnswers, } from './shuffle'; + +// ---------------------------------------------------------------------- +// Study v1 — contracts (see study-types.ts) and pure modules +// ---------------------------------------------------------------------- + +export type { + Confidence, + StudyMode, + ResponseRecord, + ResponseStore, + ScheduleEntry, + ScheduleStore, + StudySettings, + StudyStore, + CategoryBudget, + SelectionRequest, +} from './study-types'; + +export { + DEFAULT_STUDY_SETTINGS, + STUDY_STORAGE_KEYS, + RESPONSE_LOG_CAP, + newScheduleEntry, +} from './study-types'; + +export { qualityOf, applySM2, DEFAULT_EASE, MIN_EASE } from './scheduler'; +export { selectQuestionsV2 } from './selector'; +export type { SelectionResult, ScheduleLookup } from './selector'; +export { interleaveNearMisses } from './interleaver'; + +export { LocalStudyStore, getStudyStore } from './store'; diff --git a/src/lib/quiz/interleaver.ts b/src/lib/quiz/interleaver.ts new file mode 100644 index 0000000..de5e6da --- /dev/null +++ b/src/lib/quiz/interleaver.ts @@ -0,0 +1,22 @@ +/** + * Near-miss interleaver — contract. + * + * Track T2 owns the implementation. See V1-PLAN.md §5.3. + */ + +/** + * Re-order a list of question IDs so that items declared `confusionPairs` for + * each other are spaced ≥ N positions apart. Falls back to the input order + * if the constraint cannot be satisfied. + * + * Implementation deferred to track T2. + */ +export function interleaveNearMisses( + _ids: ReadonlyArray, + _confusionPairs: ReadonlyMap, + _minSpacing: number = 3, +): string[] { + throw new Error( + 'interleaveNearMisses: implementation deferred to track T2 (study-algorithms)', + ); +} diff --git a/src/lib/quiz/scheduler.ts b/src/lib/quiz/scheduler.ts new file mode 100644 index 0000000..d8a81d2 --- /dev/null +++ b/src/lib/quiz/scheduler.ts @@ -0,0 +1,50 @@ +/** + * SM-2 scheduler — contract & shared helper. + * + * Track T2 owns the full implementation; this file only carries the public + * signatures so dependent tracks (T1, T3) can compile against the contract. + * + * See V1-PLAN.md §5.1 (SM-2 update) and §A.1. + */ + +import type { Confidence, ScheduleEntry } from './study-types'; + +/** Initial easiness factor used by SM-2. */ +export const DEFAULT_EASE = 2.5; +/** Lower bound for easiness factor — SM-2 paper. */ +export const MIN_EASE = 1.3; + +/** + * Map a (correct, confidence) pair to an SM-2 quality score in [0, 5]. + * + * 0–2 = lapse (resets repetitions). + * 3–5 = recall (advances the streak). + * + * Pure; safe to call from anywhere. + */ +export function qualityOf(correct: boolean, confidence: Confidence): 0 | 1 | 2 | 3 | 4 | 5 { + if (correct) { + if (confidence === 'certain') return 5; + if (confidence === 'fairly-sure') return 4; + return 3; + } + if (confidence === 'unsure') return 2; + if (confidence === 'fairly-sure') return 1; + return 0; +} + +/** + * Compute the next ScheduleEntry from the previous one + the latest quality. + * + * Implementation lives on track T2's branch (`.trees/study-algorithms`). + * Until then this throws to make incomplete integration loud rather than silent. + * + * V1-PLAN §5.1. + */ +export function applySM2( + _previous: ScheduleEntry, + _quality: 0 | 1 | 2 | 3 | 4 | 5, + _now: number = Date.now(), +): ScheduleEntry { + throw new Error('applySM2: implementation deferred to track T2 (study-algorithms)'); +} diff --git a/src/lib/quiz/selector.ts b/src/lib/quiz/selector.ts new file mode 100644 index 0000000..fa8c644 --- /dev/null +++ b/src/lib/quiz/selector.ts @@ -0,0 +1,32 @@ +/** + * Due-aware question selector — contract. + * + * Track T2 owns the implementation. See V1-PLAN.md §5.2. + */ + +import type { ScheduleEntry, SelectionRequest, StudyStore } from './study-types'; + +/** Selection result is a list of question IDs in presentation order. */ +export type SelectionResult = string[]; + +/** Helper for tests / non-async callers. */ +export interface ScheduleLookup { + get(id: string): ScheduleEntry | null; +} + +/** + * Pick `request.totalCount` question IDs from `request.pool` honouring: + * 1. Due items first (up to `reviewMixRatio` of total). + * 2. Fresh-fill: never-seen items for the remainder. + * 3. Stale-fallback: if no due and no fresh, oldest reviewed. + * + * Implementation deferred to track T2. + */ +export async function selectQuestionsV2( + _request: SelectionRequest, + _store: Pick, +): Promise { + throw new Error( + 'selectQuestionsV2: implementation deferred to track T2 (study-algorithms)', + ); +} diff --git a/src/lib/quiz/store/index.ts b/src/lib/quiz/store/index.ts new file mode 100644 index 0000000..018fd0e --- /dev/null +++ b/src/lib/quiz/store/index.ts @@ -0,0 +1,14 @@ +/** + * StudyStore implementations. + * + * Track T1 (`.trees/study-foundation`) provides `LocalStudyStore`. + * Future tracks may add `RemoteStudyStore` (Cloudflare-backed) for v1.1+. + */ + +export type { StudyStore } from '../study-types'; + +/** + * Default factory — re-exported here so consumers can swap implementations + * by editing one file. Track T1 fills this in. + */ +export { LocalStudyStore, getStudyStore } from './local'; diff --git a/src/lib/quiz/store/local.ts b/src/lib/quiz/store/local.ts new file mode 100644 index 0000000..88f96ce --- /dev/null +++ b/src/lib/quiz/store/local.ts @@ -0,0 +1,74 @@ +/** + * LocalStudyStore — contract stub. + * + * Owned by track T1 (`.trees/study-foundation`). This file lives on the + * contracts branch only as a placeholder so other tracks can compile. + * + * V1-PLAN.md §A.2 contains the full skeleton. + */ + +import type { + ResponseRecord, + ScheduleEntry, + StudyStore, + StudySettings, +} from '../study-types'; +import type { UserProgress } from '../question-types'; +import { INITIAL_PROGRESS } from '../question-types'; +import { DEFAULT_STUDY_SETTINGS } from '../study-types'; + +/** + * In-memory placeholder used until track T1 ships the localStorage-backed + * implementation. Calling any non-trivial method throws so missing wiring is + * loud during development. + */ +export class LocalStudyStore implements StudyStore { + // responses + async appendResponse(_record: ResponseRecord): Promise { + throw new Error('LocalStudyStore.appendResponse: deferred to track T1'); + } + async getResponsesFor(_questionId: string): Promise { + throw new Error('LocalStudyStore.getResponsesFor: deferred to track T1'); + } + async getRecentResponses(_sinceMs: number): Promise { + throw new Error('LocalStudyStore.getRecentResponses: deferred to track T1'); + } + // schedule + async getSchedule(_questionId: string): Promise { + throw new Error('LocalStudyStore.getSchedule: deferred to track T1'); + } + async updateSchedule(_entry: ScheduleEntry): Promise { + throw new Error('LocalStudyStore.updateSchedule: deferred to track T1'); + } + async getDueItems(_now: number): Promise { + throw new Error('LocalStudyStore.getDueItems: deferred to track T1'); + } + // progress + async getProgress(): Promise { + return INITIAL_PROGRESS; + } + async updateProgress(_progress: UserProgress): Promise { + throw new Error('LocalStudyStore.updateProgress: deferred to track T1'); + } + // settings + async getSettings(): Promise { + return DEFAULT_STUDY_SETTINGS; + } + async updateSettings(_patch: Partial): Promise { + throw new Error('LocalStudyStore.updateSettings: deferred to track T1'); + } + // export/import + async exportAll(): Promise { + throw new Error('LocalStudyStore.exportAll: deferred to track T1'); + } + async importAll(_json: string): Promise<{ success: boolean; warnings: string[] }> { + throw new Error('LocalStudyStore.importAll: deferred to track T1'); + } +} + +let _singleton: LocalStudyStore | null = null; +/** Lazy singleton. Track T1 may replace with a more sophisticated factory. */ +export function getStudyStore(): StudyStore { + if (_singleton === null) _singleton = new LocalStudyStore(); + return _singleton; +} diff --git a/src/lib/quiz/study-types.ts b/src/lib/quiz/study-types.ts new file mode 100644 index 0000000..f230d9d --- /dev/null +++ b/src/lib/quiz/study-types.ts @@ -0,0 +1,162 @@ +/** + * Study system v1 contracts — shared by all parallel tracks. + * + * Owned by the orchestrator on `feature/study-v1-contracts`. + * Do not modify these types from a track branch; raise an issue and have the + * orchestrator publish the change so all tracks can rebase consistently. + * + * See V1-PLAN.md §3 (data model) and §3.6 (storage interface). + */ + +import type { Category, UserProgress } from './question-types'; + +/** Confidence rating captured on every answer (V1-PLAN §3.1). */ +export type Confidence = 'unsure' | 'fairly-sure' | 'certain'; + +/** Quiz/exam mode the response was captured in. */ +export type StudyMode = 'practice' | 'exam' | 'pretest'; + +/** A single answered-question event (append-only log). */ +export interface ResponseRecord { + questionId: string; + timestamp: number; + correct: boolean; + confidence: Confidence; + timeMs: number; + mode: StudyMode; + /** Text or stable id of the chosen option (omitted for matching/fill-blank). */ + optionChosen?: string; + /** Groups all responses from a single quiz/exam session. */ + sessionId: string; +} + +/** Versioned envelope persisted at `gemmology-study-responses`. */ +export interface ResponseStore { + version: 1; + responses: ResponseRecord[]; +} + +/** SM-2 schedule state per question (V1-PLAN §3.2). */ +export interface ScheduleEntry { + questionId: string; + /** Unix ms; <=0 means "due now". */ + nextDue: number; + /** SM-2 interval I_n in days. */ + intervalDays: number; + /** SM-2 ease factor EF; starts at 2.5, clamped to ≥1.3. */ + easeFactor: number; + /** Consecutive successful recalls (SM-2 n). */ + repetitions: number; + /** Total times answered incorrectly. */ + lapses: number; + /** Unix ms of the most recent review. */ + lastReviewed: number; + /** Total reviews (correct + incorrect). */ + totalReviews: number; +} + +/** Versioned envelope persisted at `gemmology-study-schedule`. */ +export interface ScheduleStore { + version: 1; + entries: Record; +} + +/** User-tunable behaviour (V1-PLAN §3.5). */ +export interface StudySettings { + version: 1; + /** Show correct rationale + per-distractor rationales after submit. */ + showRationaleOnSubmit: boolean; + /** Require ConfidenceTap before submit is enabled. */ + requireConfidence: boolean; + /** Last-used question count, persisted across sessions. */ + preferredQuestionCount: number; + /** Fraction of due (review) items vs new in a generated session, 0–1. */ + reviewMixRatio: number; +} + +export const DEFAULT_STUDY_SETTINGS: StudySettings = { + version: 1, + showRationaleOnSubmit: true, + requireConfidence: true, + preferredQuestionCount: 10, + reviewMixRatio: 0.7, +}; + +/** Per-category budget passed to the selector (V1-PLAN §5.2). */ +export interface CategoryBudget { + category: Category; + count: number; +} + +/** Selector input (V1-PLAN §5.2). */ +export interface SelectionRequest { + /** Question pool to draw from (already category/difficulty filtered). */ + pool: ReadonlyArray<{ id: string; category: Category; conceptTags?: string[] }>; + /** Per-category budget. */ + budgets: CategoryBudget[]; + /** Total session size; should equal sum of budgets. */ + totalCount: number; + /** Settings governing review-vs-new mix. */ + settings: Pick; + /** "Now" used for due calculations (Unix ms). */ + now: number; +} + +/** + * Storage interface implemented by both `LocalStudyStore` (v1.0) and + * `RemoteStudyStore` (v1.1+). Track T2 develops against this contract; track T1 + * provides the localStorage implementation. + * + * V1-PLAN §3.6. + */ +export interface StudyStore { + // responses + appendResponse(record: ResponseRecord): Promise; + getResponsesFor(questionId: string): Promise; + getRecentResponses(sinceMs: number): Promise; + + // schedule + getSchedule(questionId: string): Promise; + updateSchedule(entry: ScheduleEntry): Promise; + getDueItems(now: number): Promise; + + // progress + getProgress(): Promise; + updateProgress(progress: UserProgress): Promise; + + // settings + getSettings(): Promise; + updateSettings(patch: Partial): Promise; + + // export / import + exportAll(): Promise; + importAll(json: string): Promise<{ success: boolean; warnings: string[] }>; +} + +/** + * Build a fresh ScheduleEntry for a never-seen question. + * Pure helper shared by selector and scheduler so both agree on initial state. + */ +export function newScheduleEntry(questionId: string, now = Date.now()): ScheduleEntry { + return { + questionId, + nextDue: now, + intervalDays: 0, + easeFactor: 2.5, + repetitions: 0, + lapses: 0, + lastReviewed: 0, + totalReviews: 0, + }; +} + +/** Storage keys (single source of truth — never inline these). */ +export const STUDY_STORAGE_KEYS = { + responses: 'gemmology-study-responses', + schedule: 'gemmology-study-schedule', + progress: 'gemmology-study-progress', + settings: 'gemmology-study-settings', +} as const; + +/** Append-log capacity guard. */ +export const RESPONSE_LOG_CAP = 10_000; From dde63851cf561275e09e543f70f7366e72ba1d5c Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:17:59 +0700 Subject: [PATCH 02/16] =?UTF-8?q?feat(study):=20foundation=20=E2=80=94=20L?= =?UTF-8?q?ocalStudyStore,=20migrations,=20hook=20wiring=20(T1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements V1-PLAN §3.6 (StudyStore interface) and §A.2 (LocalStudyStore) with full localStorage persistence. Adds v0→v1 migration preserving existing gemmology-quiz-progress data. Wires studyStore.appendResponse and progressTracker.updateProgress into useQuiz and useExam. - LocalStudyStore: all StudyStore methods backed by localStorage, 10 000-record RESPONSE_LOG_CAP trim guard, import/merge strategy (responses append-dedupe, schedule upsert, progress overwrite-if-newer, settings always overwrite) - migrations.ts: idempotent v0→v1 migration, version sentinel at gemmology-study-version, lifts gemmology-quiz-progress into versioned envelope - useQuiz.submitAnswer: logs ResponseRecord (confidence defaults 'fairly-sure' pending T3 ConfidenceTap), updateProgress on submitQuiz - useExam.submitExam: logs one ResponseRecord per answered question, supports per-question confidenceMap (defaults 'fairly-sure' pending T3 ConfidenceTap) - Adds Vitest + @vitest/coverage-v8 (devDeps); 50 tests, 87 %+ coverage on store/**, 100 % function coverage - Fixes Quiz.tsx onClick type error introduced by optional-Confidence signature --- package-lock.json | 551 ++++++++++++++++++++++++-- package.json | 9 +- src/components/quiz/Quiz.tsx | 2 +- src/hooks/useExam.ts | 103 ++++- src/hooks/useQuiz.ts | 87 +++- src/lib/quiz/store/local.test.ts | 530 +++++++++++++++++++++++++ src/lib/quiz/store/local.ts | 384 ++++++++++++++++-- src/lib/quiz/store/migrations.test.ts | 249 ++++++++++++ src/lib/quiz/store/migrations.ts | 154 +++++++ vitest.config.ts | 27 ++ 10 files changed, 1998 insertions(+), 98 deletions(-) create mode 100644 src/lib/quiz/store/local.test.ts create mode 100644 src/lib/quiz/store/migrations.test.ts create mode 100644 src/lib/quiz/store/migrations.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 9ebea73..b068969 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,12 +38,14 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/three": "^0.164.0", + "@vitest/coverage-v8": "^4.1.5", "autoprefixer": "^10.4.20", "playwright": "^1.58.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.15", "tsx": "^4.0.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^4.1.5" } }, "node_modules/@alloc/quick-lru": { @@ -363,12 +365,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -449,9 +451,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -461,6 +463,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@capsizecss/unpack": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", @@ -2179,6 +2191,13 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/typography": { "version": "0.5.19", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", @@ -2238,6 +2257,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2247,6 +2277,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dompurify": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", @@ -2442,6 +2479,150 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2590,6 +2771,35 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/astro": { "version": "5.16.15", "resolved": "https://registry.npmjs.org/astro/-/astro-5.16.15.tgz", @@ -3111,6 +3321,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -3796,6 +4016,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expressive-code": { "version": "0.41.6", "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.41.6.tgz", @@ -4036,6 +4266,16 @@ "uncrypto": "^0.1.3" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4515,6 +4755,52 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports/node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/its-fine": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", @@ -4674,16 +4960,45 @@ } }, "node_modules/magicast": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", - "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -5703,6 +6018,17 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ofetch": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", @@ -5843,6 +6169,13 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -6810,6 +7143,13 @@ "@types/hast": "^3.0.4" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -6878,6 +7218,13 @@ "integrity": "sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==", "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -6919,6 +7266,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-replace-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", @@ -7002,6 +7356,19 @@ "node": ">= 6" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -7174,6 +7541,13 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -7199,6 +7573,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7330,7 +7714,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7347,7 +7730,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7364,7 +7746,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7381,7 +7762,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7398,7 +7778,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7415,7 +7794,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7432,7 +7810,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7449,7 +7826,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7466,7 +7842,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7483,7 +7858,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7500,7 +7874,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7517,7 +7890,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7534,7 +7906,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7551,7 +7922,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7568,7 +7938,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7585,7 +7954,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7602,7 +7970,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7619,7 +7986,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7636,7 +8002,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7653,7 +8018,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7670,7 +8034,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7687,7 +8050,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7704,7 +8066,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7721,7 +8082,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7738,7 +8098,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7755,7 +8114,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7811,7 +8169,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8264,6 +8621,103 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -8309,6 +8763,23 @@ "node": ">=4" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", diff --git a/package.json b/package.json index ac77709..f07159e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,10 @@ "prerender": "node scripts/prerender-crystals.js", "check-svgs": "node scripts/audit-crystal-svgs.mjs", "prebuild": "npm run sync && npm run copy-db && npm run check-svgs", - "generate-og": "npm run build && node scripts/generate-og-image.js" + "generate-og": "npm run build && node scripts/generate-og-image.js", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@astrojs/node": "^9.5.2", @@ -44,11 +47,13 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/three": "^0.164.0", + "@vitest/coverage-v8": "^4.1.5", "autoprefixer": "^10.4.20", "playwright": "^1.58.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.15", "tsx": "^4.0.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^4.1.5" } } diff --git a/src/components/quiz/Quiz.tsx b/src/components/quiz/Quiz.tsx index f9fd4eb..ffaa8bd 100644 --- a/src/components/quiz/Quiz.tsx +++ b/src/components/quiz/Quiz.tsx @@ -107,7 +107,7 @@ export function Quiz({
{/* Submit answer button (practice mode) */} {config.mode === 'practice' && !isSubmitted && hasAnswer && ( - )} diff --git a/src/hooks/useExam.ts b/src/hooks/useExam.ts index d7d7a2c..9777f6b 100644 --- a/src/hooks/useExam.ts +++ b/src/hooks/useExam.ts @@ -1,6 +1,11 @@ /** * React hook for managing exam state with timer. * Extends useQuiz with countdown functionality and exam-specific features. + * + * Study v1 additions (T1): + * - Wires studyStore.appendResponse for every answered question at submit time. + * - Wires progressTracker.updateProgress + studyStore.updateProgress on submit. + * - sessionId groups all responses from a single exam sitting. */ import { useState, useCallback, useEffect, useRef } from 'react'; @@ -14,8 +19,12 @@ import { calculateResults, serializeQuizState, deserializeQuizState, + checkAnswer, } from '../lib/quiz'; import { useLocalStorage, STORAGE_KEYS } from './useLocalStorage'; +import { getStudyStore } from '../lib/quiz/store'; +import { updateProgress } from '../lib/quiz/progress-tracker'; +import type { Confidence } from '../lib/quiz/study-types'; interface UseExamOptions { /** Exam configuration */ @@ -65,8 +74,15 @@ interface UseExamReturn { isQuestionFlagged: (questionId: string) => boolean; /** Check if a question is answered */ isQuestionAnswered: (questionId: string) => boolean; - /** Submit the exam */ - submitExam: () => void; + /** + * Submit the exam. + * + * @param confidenceMap - Per-question confidence captured by ConfidenceTap + * (keyed by questionId). Until T3's ConfidenceTap lands, pass an empty + * Map or omit; answers will record 'fairly-sure'. + * TODO(T3): wire ConfidenceTap — remove default once component is available. + */ + submitExam: (confidenceMap?: Map) => void; /** Reset the exam */ resetExam: () => void; /** Pause the timer */ @@ -81,12 +97,26 @@ interface SerializedExamState { isPaused: boolean; } +/** Generate a random session identifier. */ +function makeSessionId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + export function useExam({ config, questions, timeLimit, autoSubmitOnTimeout = true, }: UseExamOptions): UseExamReturn { + // Study store singleton — stable reference. + const store = useRef(getStudyStore()).current; + + // Unique identifier for this exam session. + const sessionId = useRef(makeSessionId()).current; + + // Track per-question start times for timeMs computation. + const questionStartTimes = useRef>(new Map()); + // Persist exam state const [savedState, setSavedState, clearSavedState] = useLocalStorage( STORAGE_KEYS.EXAM_STATE, @@ -175,6 +205,7 @@ export function useExam({ if (isTimeExpired && autoSubmitOnTimeout && !isComplete) { submitExam(); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isTimeExpired, autoSubmitOnTimeout, isComplete]); // Save state periodically @@ -189,10 +220,22 @@ export function useExam({ } }, [state, timeRemaining, isPaused, isComplete, setSavedState]); + // Track when a user first views/interacts with each question. + useEffect(() => { + if (currentQuestion && !questionStartTimes.current.has(currentQuestion.id)) { + questionStartTimes.current.set(currentQuestion.id, Date.now()); + } + }, [currentQuestion]); + // Select an answer const selectAnswer = useCallback((answer: string | string[]) => { if (!currentQuestion || state.submitted) return; + // Record start time on first answer for this question. + if (!questionStartTimes.current.has(currentQuestion.id)) { + questionStartTimes.current.set(currentQuestion.id, Date.now()); + } + const newAnswers = new Map(state.answers); newAnswers.set(currentQuestion.id, answer); @@ -260,11 +303,23 @@ export function useExam({ return state.answers.has(questionId); }, [state.answers]); - // Submit the exam - const submitExam = useCallback(() => { - const finalState = { + /** + * Submit the exam. + * + * Logs one ResponseRecord per answered question to the study store, then + * updates progress via progressTracker + store. + * + * @param confidenceMap - Per-question confidence (keyed by questionId). + * Defaults to 'fairly-sure' for all until ConfidenceTap (T3) is wired. + * TODO(T3): wire ConfidenceTap — remove default once component is available. + */ + const submitExam = useCallback(( + confidenceMap: Map = new Map() + ) => { + const now = Date.now(); + const finalState: QuizState = { ...state, - endTime: Date.now(), + endTime: now, submitted: true, }; @@ -277,7 +332,40 @@ export function useExam({ // Clear saved state clearSavedState(); - }, [state, config, clearSavedState]); + + // --- Study v1: log one ResponseRecord per answered question --- + for (const [questionId, answer] of finalState.answers) { + const question = finalState.questions.find(q => q.id === questionId); + if (!question) continue; + + const correct = checkAnswer(question, answer); + // TODO(T3): wire ConfidenceTap — remove default once component is available. + const confidence: Confidence = confidenceMap.get(questionId) ?? 'fairly-sure'; + const startTime = questionStartTimes.current.get(questionId) ?? finalState.startTime; + const timeMs = now - startTime; + + store.appendResponse({ + questionId, + timestamp: now, + correct, + confidence, + timeMs, + mode: 'exam', + optionChosen: Array.isArray(answer) ? answer.join(',') : answer, + sessionId, + }).catch((err: unknown) => { + console.warn('[useExam] appendResponse failed:', err); + }); + } + + // --- Study v1: update progress --- + store.getProgress().then(currentProgress => { + const updated = updateProgress(currentProgress, examResults); + return store.updateProgress(updated); + }).catch((err: unknown) => { + console.warn('[useExam] updateProgress failed:', err); + }); + }, [state, config, clearSavedState, store, sessionId]); // Reset the exam const resetExam = useCallback(() => { @@ -286,6 +374,7 @@ export function useExam({ setScore(null); setTimeRemaining(timeLimit); setIsPaused(false); + questionStartTimes.current = new Map(); setState({ questions, diff --git a/src/hooks/useQuiz.ts b/src/hooks/useQuiz.ts index 1dee031..c0838d0 100644 --- a/src/hooks/useQuiz.ts +++ b/src/hooks/useQuiz.ts @@ -1,9 +1,14 @@ /** * React hook for managing quiz state. * Handles question navigation, answer tracking, and results calculation. + * + * Study v1 additions (T1): + * - Wires studyStore.appendResponse on every submitted answer. + * - Wires progressTracker.updateProgress + studyStore.updateProgress on quiz submit. + * - sessionId is generated once per hook lifetime and groups all responses. */ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo, useRef } from 'react'; import type { Question, QuizConfig, @@ -17,6 +22,9 @@ import { deserializeQuizState, } from '../lib/quiz'; import { useLocalStorage, STORAGE_KEYS } from './useLocalStorage'; +import { getStudyStore } from '../lib/quiz/store'; +import { updateProgress } from '../lib/quiz/progress-tracker'; +import type { Confidence } from '../lib/quiz/study-types'; interface UseQuizOptions { /** Quiz configuration */ @@ -45,7 +53,7 @@ interface UseQuizReturn { /** Select an answer for the current question */ selectAnswer: (answer: string | string[]) => void; /** Submit the current answer (practice mode) */ - submitAnswer: () => void; + submitAnswer: (confidence?: Confidence) => void; /** Go to the next question */ nextQuestion: () => void; /** Go to the previous question */ @@ -60,11 +68,25 @@ interface UseQuizReturn { resetQuiz: () => void; } +/** Generate a random session identifier. */ +function makeSessionId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + export function useQuiz({ config, questions, persist = false, }: UseQuizOptions): UseQuizReturn { + // Study store singleton — stable reference. + const store = useRef(getStudyStore()).current; + + // Unique identifier for this quiz session (groups all responses). + const sessionId = useRef(makeSessionId()).current; + + // Per-question start time so we can compute timeMs. + const questionStartTime = useRef(Date.now()); + // Initialize state const [savedState, setSavedState, clearSavedState] = useLocalStorage( STORAGE_KEYS.QUIZ_STATE, @@ -136,7 +158,11 @@ export function useQuiz({ const selectAnswer = useCallback((answer: string | string[]) => { if (!currentQuestion || state.submitted) return; + // Reset per-question timer when user selects (first interaction). const newAnswers = new Map(state.answers); + if (!newAnswers.has(currentQuestion.id)) { + questionStartTime.current = Date.now(); + } newAnswers.set(currentQuestion.id, answer); saveState({ @@ -145,16 +171,44 @@ export function useQuiz({ }); }, [currentQuestion, state, saveState]); - // Submit the current answer (practice mode - show feedback) - const submitAnswer = useCallback(() => { + /** + * Submit the current answer (practice mode — shows feedback). + * + * @param confidence - Confidence level captured by ConfidenceTap. + * Defaults to 'fairly-sure' until track T3's ConfidenceTap lands. + * TODO(T3): wire ConfidenceTap — remove default once component is available. + */ + const submitAnswer = useCallback(( + // TODO(T3): wire ConfidenceTap — remove default once component is available. + confidence: Confidence = 'fairly-sure' + ) => { if (!currentQuestion || !hasAnswer) return; setSubmittedQuestions(prev => new Set(prev).add(currentQuestion.id)); - }, [currentQuestion, hasAnswer]); - // Navigate to next question + // --- Study v1: log the response --- + const answer = state.answers.get(currentQuestion.id); + const correct = answer !== undefined && checkAnswer(currentQuestion, answer); + const timeMs = Date.now() - questionStartTime.current; + + store.appendResponse({ + questionId: currentQuestion.id, + timestamp: Date.now(), + correct, + confidence, + timeMs, + mode: 'practice', + optionChosen: Array.isArray(answer) ? answer.join(',') : answer, + sessionId, + }).catch((err: unknown) => { + console.warn('[useQuiz] appendResponse failed:', err); + }); + }, [currentQuestion, hasAnswer, state.answers, store, sessionId]); + + // Navigate to next question; reset the per-question timer. const nextQuestion = useCallback(() => { if (state.currentIndex < state.questions.length - 1) { + questionStartTime.current = Date.now(); saveState({ ...state, currentIndex: state.currentIndex + 1, @@ -175,6 +229,7 @@ export function useQuiz({ // Navigate to specific question const goToQuestion = useCallback((index: number) => { if (index >= 0 && index < state.questions.length) { + questionStartTime.current = Date.now(); saveState({ ...state, currentIndex: index, @@ -199,19 +254,33 @@ export function useQuiz({ }); }, [currentQuestion, state, saveState]); - // Submit the entire quiz + /** + * Submit the entire quiz. + * After state update, wires progress-tracker and persists via the store. + */ const submitQuiz = useCallback(() => { - saveState({ + const finalState: QuizState = { ...state, endTime: Date.now(), submitted: true, + }; + saveState(finalState); + + // --- Study v1: update progress --- + const quizResult = calculateResults(finalState, config); + store.getProgress().then(currentProgress => { + const updated = updateProgress(currentProgress, quizResult); + return store.updateProgress(updated); + }).catch((err: unknown) => { + console.warn('[useQuiz] updateProgress failed:', err); }); - }, [state, saveState]); + }, [state, saveState, config, store]); // Reset the quiz const resetQuiz = useCallback(() => { setSubmittedQuestions(new Set()); clearSavedState(); + questionStartTime.current = Date.now(); setState({ questions, diff --git a/src/lib/quiz/store/local.test.ts b/src/lib/quiz/store/local.test.ts new file mode 100644 index 0000000..16fcebe --- /dev/null +++ b/src/lib/quiz/store/local.test.ts @@ -0,0 +1,530 @@ +/** + * Tests for LocalStudyStore. + * Runs in a Node.js environment with a Map-backed localStorage mock. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { LocalStudyStore, getStudyStore } from './local'; +import type { ResponseRecord, ScheduleEntry, StudySettings } from '../study-types'; +import { RESPONSE_LOG_CAP, STUDY_STORAGE_KEYS } from '../study-types'; +import { INITIAL_PROGRESS } from '../question-types'; + +// --------------------------------------------------------------------------- +// localStorage mock +// --------------------------------------------------------------------------- + +class LocalStorageMock implements Storage { + private store: Map = new Map(); + get length() { return this.store.size; } + clear() { this.store.clear(); } + getItem(key: string) { return this.store.get(key) ?? null; } + setItem(key: string, value: string) { this.store.set(key, value); } + removeItem(key: string) { this.store.delete(key); } + key(index: number) { return [...this.store.keys()][index] ?? null; } +} + +let mockStorage: LocalStorageMock; + +// Override the global localStorage before each test. +beforeEach(() => { + mockStorage = new LocalStorageMock(); + Object.defineProperty(globalThis, 'localStorage', { + value: mockStorage, + writable: true, + configurable: true, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeResponse(overrides: Partial = {}): ResponseRecord { + return { + questionId: 'q-001', + timestamp: Date.now(), + correct: true, + confidence: 'fairly-sure', + timeMs: 1500, + mode: 'practice', + sessionId: 'test-session-1', + ...overrides, + }; +} + +function makeScheduleEntry(questionId = 'q-001', overrides: Partial = {}): ScheduleEntry { + return { + questionId, + nextDue: Date.now(), + intervalDays: 1, + easeFactor: 2.5, + repetitions: 1, + lapses: 0, + lastReviewed: Date.now() - 86_400_000, + totalReviews: 1, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// appendResponse +// --------------------------------------------------------------------------- + +describe('appendResponse', () => { + it('stores a response and reads it back via getResponsesFor', async () => { + const store = new LocalStudyStore(); + const rec = makeResponse({ questionId: 'q-001' }); + await store.appendResponse(rec); + const results = await store.getResponsesFor('q-001'); + expect(results).toHaveLength(1); + expect(results[0]).toEqual(rec); + }); + + it('stores multiple responses for the same question', async () => { + const store = new LocalStudyStore(); + await store.appendResponse(makeResponse({ questionId: 'q-abc', timestamp: 1000 })); + await store.appendResponse(makeResponse({ questionId: 'q-abc', timestamp: 2000 })); + const results = await store.getResponsesFor('q-abc'); + expect(results).toHaveLength(2); + }); + + it('does not cross-contaminate different questions', async () => { + const store = new LocalStudyStore(); + await store.appendResponse(makeResponse({ questionId: 'q-A' })); + await store.appendResponse(makeResponse({ questionId: 'q-B' })); + expect(await store.getResponsesFor('q-A')).toHaveLength(1); + expect(await store.getResponsesFor('q-B')).toHaveLength(1); + }); + + it('trims to RESPONSE_LOG_CAP when exceeded', async () => { + const store = new LocalStudyStore(); + const CAP = RESPONSE_LOG_CAP; + + // Pre-populate storage with CAP records directly (bypassing the async loop) + // so the test remains fast. Then append 5 more via the store API to verify + // the trim logic kicks in. + const existing: ResponseRecord[] = Array.from({ length: CAP }, (_, i) => ({ + questionId: `q-existing-${i}`, + timestamp: i, + correct: true, + confidence: 'fairly-sure' as const, + timeMs: 100, + mode: 'practice' as const, + sessionId: 'session-cap-test', + })); + mockStorage.setItem( + STUDY_STORAGE_KEYS.responses, + JSON.stringify({ version: 1, responses: existing }) + ); + + // Append 5 more — should trigger trim. + for (let i = 0; i < 5; i++) { + await store.appendResponse( + makeResponse({ questionId: `q-new-${i}`, timestamp: CAP + i }) + ); + } + + const all = await store.getRecentResponses(0); + expect(all).toHaveLength(CAP); + // The 5 oldest (timestamp 0–4) should have been trimmed. + expect(all.some(r => r.questionId === 'q-existing-0')).toBe(false); + expect(all.some(r => r.questionId === 'q-existing-4')).toBe(false); + // The newest should be present. + expect(all.some(r => r.questionId === 'q-new-4')).toBe(true); + }); + + it('RESPONSE_LOG_CAP constant equals 10 000', () => { + expect(RESPONSE_LOG_CAP).toBe(10_000); + }); +}); + +// --------------------------------------------------------------------------- +// getRecentResponses +// --------------------------------------------------------------------------- + +describe('getRecentResponses', () => { + it('returns all records when sinceMs = 0', async () => { + const store = new LocalStudyStore(); + await store.appendResponse(makeResponse({ timestamp: 1000 })); + await store.appendResponse(makeResponse({ timestamp: 2000 })); + const all = await store.getRecentResponses(0); + expect(all).toHaveLength(2); + }); + + it('filters by sinceMs correctly', async () => { + const store = new LocalStudyStore(); + await store.appendResponse(makeResponse({ timestamp: 1000 })); + await store.appendResponse(makeResponse({ timestamp: 5000 })); + await store.appendResponse(makeResponse({ timestamp: 9000 })); + const recent = await store.getRecentResponses(3000); + expect(recent).toHaveLength(2); + expect(recent.every(r => r.timestamp >= 3000)).toBe(true); + }); + + it('returns empty array when store is empty', async () => { + const store = new LocalStudyStore(); + expect(await store.getRecentResponses(0)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// getResponsesFor +// --------------------------------------------------------------------------- + +describe('getResponsesFor', () => { + it('returns empty array for a never-seen questionId', async () => { + const store = new LocalStudyStore(); + expect(await store.getResponsesFor('unknown')).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// getSchedule / updateSchedule +// --------------------------------------------------------------------------- + +describe('schedule', () => { + it('returns null for an unseen question', async () => { + const store = new LocalStudyStore(); + expect(await store.getSchedule('q-unseen')).toBeNull(); + }); + + it('stores and retrieves a schedule entry', async () => { + const store = new LocalStudyStore(); + const entry = makeScheduleEntry('q-001'); + await store.updateSchedule(entry); + const retrieved = await store.getSchedule('q-001'); + expect(retrieved).toEqual(entry); + }); + + it('overwrites an existing entry', async () => { + const store = new LocalStudyStore(); + const entry1 = makeScheduleEntry('q-001', { intervalDays: 1 }); + const entry2 = makeScheduleEntry('q-001', { intervalDays: 6 }); + await store.updateSchedule(entry1); + await store.updateSchedule(entry2); + const retrieved = await store.getSchedule('q-001'); + expect(retrieved?.intervalDays).toBe(6); + }); + + it('persists multiple independent entries', async () => { + const store = new LocalStudyStore(); + await store.updateSchedule(makeScheduleEntry('q-A', { intervalDays: 1 })); + await store.updateSchedule(makeScheduleEntry('q-B', { intervalDays: 6 })); + expect((await store.getSchedule('q-A'))?.intervalDays).toBe(1); + expect((await store.getSchedule('q-B'))?.intervalDays).toBe(6); + }); +}); + +// --------------------------------------------------------------------------- +// getDueItems +// --------------------------------------------------------------------------- + +describe('getDueItems', () => { + it('returns nothing when schedule is empty', async () => { + const store = new LocalStudyStore(); + expect(await store.getDueItems(Date.now())).toEqual([]); + }); + + it('returns questionIds with nextDue <= now', async () => { + const store = new LocalStudyStore(); + const now = Date.now(); + await store.updateSchedule(makeScheduleEntry('due-now', { nextDue: now - 1000 })); + await store.updateSchedule(makeScheduleEntry('future', { nextDue: now + 100_000 })); + const due = await store.getDueItems(now); + expect(due).toContain('due-now'); + expect(due).not.toContain('future'); + }); + + it('includes items where nextDue equals now exactly', async () => { + const store = new LocalStudyStore(); + const now = 1_000_000; + await store.updateSchedule(makeScheduleEntry('exact', { nextDue: now })); + const due = await store.getDueItems(now); + expect(due).toContain('exact'); + }); +}); + +// --------------------------------------------------------------------------- +// getProgress / updateProgress +// --------------------------------------------------------------------------- + +describe('progress', () => { + it('returns INITIAL_PROGRESS when nothing has been stored', async () => { + const store = new LocalStudyStore(); + const progress = await store.getProgress(); + expect(progress).toEqual(INITIAL_PROGRESS); + }); + + it('stores and retrieves progress', async () => { + const store = new LocalStudyStore(); + const p = { ...INITIAL_PROGRESS, totalQuizzes: 5, totalCorrect: 20 }; + await store.updateProgress(p); + const retrieved = await store.getProgress(); + expect(retrieved.totalQuizzes).toBe(5); + expect(retrieved.totalCorrect).toBe(20); + }); +}); + +// --------------------------------------------------------------------------- +// getSettings / updateSettings +// --------------------------------------------------------------------------- + +describe('settings', () => { + it('returns default settings when nothing has been stored', async () => { + const store = new LocalStudyStore(); + const settings = await store.getSettings(); + expect(settings.version).toBe(1); + expect(settings.showRationaleOnSubmit).toBe(true); + expect(settings.requireConfidence).toBe(true); + }); + + it('persists settings changes', async () => { + const store = new LocalStudyStore(); + await store.updateSettings({ requireConfidence: false, preferredQuestionCount: 20 }); + const settings = await store.getSettings(); + expect(settings.requireConfidence).toBe(false); + expect(settings.preferredQuestionCount).toBe(20); + }); + + it('merges partial updates with existing settings', async () => { + const store = new LocalStudyStore(); + await store.updateSettings({ requireConfidence: false }); + await store.updateSettings({ preferredQuestionCount: 25 }); + const settings = await store.getSettings(); + expect(settings.requireConfidence).toBe(false); + expect(settings.preferredQuestionCount).toBe(25); + // Defaults unchanged. + expect(settings.showRationaleOnSubmit).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// exportAll / importAll — round-trip +// --------------------------------------------------------------------------- + +describe('exportAll / importAll', () => { + it('exports non-empty JSON', async () => { + const store = new LocalStudyStore(); + const json = await store.exportAll(); + expect(typeof json).toBe('string'); + const payload = JSON.parse(json); + expect(payload.version).toBe(1); + expect(Array.isArray(payload.responses)).toBe(true); + expect(typeof payload.schedule).toBe('object'); + expect(typeof payload.progress).toBe('object'); + expect(typeof payload.settings).toBe('object'); + }); + + it('round-trips 5 responses + schedule + progress + settings', async () => { + const store = new LocalStudyStore(); + + // Populate data. + for (let i = 0; i < 5; i++) { + await store.appendResponse(makeResponse({ + questionId: `q-${i}`, + timestamp: 1000 + i, + correct: i % 2 === 0, + })); + } + await store.updateSchedule(makeScheduleEntry('q-0', { intervalDays: 3 })); + await store.updateProgress({ ...INITIAL_PROGRESS, totalQuizzes: 2 }); + await store.updateSettings({ requireConfidence: false }); + + const json = await store.exportAll(); + + // Wipe store. + mockStorage.clear(); + + // Fresh store — import. + const store2 = new LocalStudyStore(); + const result = await store2.importAll(json); + expect(result.success).toBe(true); + + const responses = await store2.getRecentResponses(0); + expect(responses).toHaveLength(5); + + const sched = await store2.getSchedule('q-0'); + expect(sched?.intervalDays).toBe(3); + + const progress = await store2.getProgress(); + expect(progress.totalQuizzes).toBe(2); + + const settings = await store2.getSettings(); + expect(settings.requireConfidence).toBe(false); + }); + + it('returns success:false on invalid JSON', async () => { + const store = new LocalStudyStore(); + const result = await store.importAll('not json at all {{{'); + expect(result.success).toBe(false); + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it('returns success:false on wrong shape', async () => { + const store = new LocalStudyStore(); + const result = await store.importAll(JSON.stringify({ foo: 'bar' })); + expect(result.success).toBe(false); + }); + + it('deduplicates responses on import (same questionId + timestamp)', async () => { + const store = new LocalStudyStore(); + const rec = makeResponse({ questionId: 'q-dup', timestamp: 99999 }); + await store.appendResponse(rec); + + const json = await store.exportAll(); + + // Import into same store — should not duplicate. + await store.importAll(json); + const all = await store.getResponsesFor('q-dup'); + expect(all).toHaveLength(1); + }); + + it('appends new responses from import', async () => { + const store = new LocalStudyStore(); + await store.appendResponse(makeResponse({ questionId: 'q-local', timestamp: 100 })); + + // Export from a "remote" store with a different response. + const remoteStore = new LocalStudyStore(); + await remoteStore.appendResponse(makeResponse({ questionId: 'q-remote', timestamp: 200 })); + const remoteJson = await remoteStore.exportAll(); + + // Import remote data into local store. + await store.importAll(remoteJson); + const all = await store.getRecentResponses(0); + expect(all.some(r => r.questionId === 'q-local')).toBe(true); + expect(all.some(r => r.questionId === 'q-remote')).toBe(true); + }); + + it('upserts schedule entries by questionId on import', async () => { + const store = new LocalStudyStore(); + await store.updateSchedule(makeScheduleEntry('q-X', { intervalDays: 1 })); + + // Build a payload with an updated schedule. + const payload = JSON.parse(await store.exportAll()); + payload.schedule['q-X'].intervalDays = 10; + await store.importAll(JSON.stringify(payload)); + + const sched = await store.getSchedule('q-X'); + expect(sched?.intervalDays).toBe(10); + }); + + it('overwrites progress if imported lastActivity is newer', async () => { + const store = new LocalStudyStore(); + await store.updateProgress({ ...INITIAL_PROGRESS, totalQuizzes: 1, lastActivity: 1000 }); + + // Payload with newer lastActivity. + const newerPayload = JSON.parse(await store.exportAll()); + newerPayload.progress.totalQuizzes = 5; + newerPayload.progress.lastActivity = 9999999; + + await store.importAll(JSON.stringify(newerPayload)); + const progress = await store.getProgress(); + expect(progress.totalQuizzes).toBe(5); + }); + + it('does not overwrite progress if imported lastActivity is older', async () => { + const store = new LocalStudyStore(); + await store.updateProgress({ ...INITIAL_PROGRESS, totalQuizzes: 3, lastActivity: 9999999 }); + + // Payload with older lastActivity. + const olderPayload = JSON.parse(await store.exportAll()); + olderPayload.progress.totalQuizzes = 1; + olderPayload.progress.lastActivity = 1; + + await store.importAll(JSON.stringify(olderPayload)); + const progress = await store.getProgress(); + expect(progress.totalQuizzes).toBe(3); // Unchanged. + }); +}); + +// --------------------------------------------------------------------------- +// Storage round-trip shape preservation +// --------------------------------------------------------------------------- + +describe('storage round-trip shape', () => { + it('ResponseRecord shape is preserved exactly through storage', async () => { + const store = new LocalStudyStore(); + const rec: ResponseRecord = { + questionId: 'shape-test', + timestamp: 1234567890, + correct: false, + confidence: 'unsure', + timeMs: 3200, + mode: 'exam', + optionChosen: 'Spinel', + sessionId: 'session-abc', + }; + await store.appendResponse(rec); + const [retrieved] = await store.getResponsesFor('shape-test'); + expect(retrieved).toStrictEqual(rec); + }); + + it('ScheduleEntry shape is preserved exactly through storage', async () => { + const store = new LocalStudyStore(); + const entry: ScheduleEntry = { + questionId: 'sched-shape', + nextDue: 111111, + intervalDays: 6, + easeFactor: 2.3, + repetitions: 2, + lapses: 1, + lastReviewed: 99999, + totalReviews: 3, + }; + await store.updateSchedule(entry); + const retrieved = await store.getSchedule('sched-shape'); + expect(retrieved).toStrictEqual(entry); + }); + + it('StudySettings shape is preserved exactly through storage', async () => { + const store = new LocalStudyStore(); + const settings: StudySettings = { + version: 1, + showRationaleOnSubmit: false, + requireConfidence: false, + preferredQuestionCount: 15, + reviewMixRatio: 0.5, + }; + await store.updateSettings(settings); + const retrieved = await store.getSettings(); + expect(retrieved).toStrictEqual(settings); + }); +}); + +// --------------------------------------------------------------------------- +// Corruption resilience +// --------------------------------------------------------------------------- + +describe('corruption resilience', () => { + it('returns fallback when localStorage contains corrupt JSON', async () => { + mockStorage.setItem(STUDY_STORAGE_KEYS.responses, 'CORRUPT{{{'); + const store = new LocalStudyStore(); + const all = await store.getRecentResponses(0); + expect(all).toEqual([]); + }); + + it('returns fallback schedule when localStorage is corrupt', async () => { + mockStorage.setItem(STUDY_STORAGE_KEYS.schedule, 'not-json'); + const store = new LocalStudyStore(); + const due = await store.getDueItems(Date.now()); + expect(due).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Singleton factory +// --------------------------------------------------------------------------- + +describe('getStudyStore singleton', () => { + it('returns the same instance on repeated calls', () => { + // Reset singleton between describe blocks via the module. + // Since vitest re-evaluates modules per test file we can test this directly. + const a = getStudyStore(); + const b = getStudyStore(); + expect(a).toBe(b); + }); +}); diff --git a/src/lib/quiz/store/local.ts b/src/lib/quiz/store/local.ts index 88f96ce..308fa98 100644 --- a/src/lib/quiz/store/local.ts +++ b/src/lib/quiz/store/local.ts @@ -1,73 +1,379 @@ /** - * LocalStudyStore — contract stub. + * LocalStudyStore — localStorage-backed implementation of StudyStore. * - * Owned by track T1 (`.trees/study-foundation`). This file lives on the - * contracts branch only as a placeholder so other tracks can compile. + * Owned by track T1 (`.trees/study-foundation`). + * See V1-PLAN.md §3.6 (interface) and §A.2 (skeleton). * - * V1-PLAN.md §A.2 contains the full skeleton. + * Storage keys are sourced from STUDY_STORAGE_KEYS — never inline them. + * The response log is capped at RESPONSE_LOG_CAP (10 000) records; older + * entries are dropped from the head when the cap is exceeded. */ import type { + StudyStore, ResponseRecord, + ResponseStore, ScheduleEntry, - StudyStore, + ScheduleStore, StudySettings, } from '../study-types'; import type { UserProgress } from '../question-types'; -import { INITIAL_PROGRESS } from '../question-types'; -import { DEFAULT_STUDY_SETTINGS } from '../study-types'; +import { + INITIAL_PROGRESS, +} from '../question-types'; +import { + DEFAULT_STUDY_SETTINGS, + STUDY_STORAGE_KEYS, + RESPONSE_LOG_CAP, +} from '../study-types'; +import { runMigrations } from './migrations'; + +// --------------------------------------------------------------------------- +// Internal envelope shapes +// --------------------------------------------------------------------------- + +interface ProgressStore { + version: 1; + progress: UserProgress; +} + +interface SettingsStore { + version: 1; + settings: StudySettings; +} + +// --------------------------------------------------------------------------- +// Export / import payload +// --------------------------------------------------------------------------- + +interface ExportPayload { + version: 1; + responses: ResponseRecord[]; + schedule: Record; + progress: UserProgress; + settings: StudySettings; +} + +// --------------------------------------------------------------------------- +// LocalStudyStore +// --------------------------------------------------------------------------- -/** - * In-memory placeholder used until track T1 ships the localStorage-backed - * implementation. Calling any non-trivial method throws so missing wiring is - * loud during development. - */ export class LocalStudyStore implements StudyStore { - // responses - async appendResponse(_record: ResponseRecord): Promise { - throw new Error('LocalStudyStore.appendResponse: deferred to track T1'); + /** Run migrations on construction so the store is always up-to-date. */ + constructor() { + // Only run in browser environments; tests that mock localStorage still work. + if (typeof localStorage !== 'undefined') { + runMigrations(localStorage); + } } - async getResponsesFor(_questionId: string): Promise { - throw new Error('LocalStudyStore.getResponsesFor: deferred to track T1'); + + // ------------------------------------------------------------------------- + // Responses + // ------------------------------------------------------------------------- + + async appendResponse(record: ResponseRecord): Promise { + const store = this._readResponses(); + store.responses.push(record); + if (store.responses.length > RESPONSE_LOG_CAP) { + // Trim from the head (oldest first). + store.responses.splice(0, store.responses.length - RESPONSE_LOG_CAP); + } + this._write(STUDY_STORAGE_KEYS.responses, store); } - async getRecentResponses(_sinceMs: number): Promise { - throw new Error('LocalStudyStore.getRecentResponses: deferred to track T1'); + + async getResponsesFor(questionId: string): Promise { + const store = this._readResponses(); + return store.responses.filter(r => r.questionId === questionId); + } + + /** + * Return all responses with timestamp >= sinceMs. + * Pass 0 to get every recorded response. + */ + async getRecentResponses(sinceMs: number): Promise { + const store = this._readResponses(); + return store.responses.filter(r => r.timestamp >= sinceMs); } - // schedule - async getSchedule(_questionId: string): Promise { - throw new Error('LocalStudyStore.getSchedule: deferred to track T1'); + + // ------------------------------------------------------------------------- + // Schedule + // ------------------------------------------------------------------------- + + async getSchedule(questionId: string): Promise { + const store = this._readSchedule(); + return store.entries[questionId] ?? null; } - async updateSchedule(_entry: ScheduleEntry): Promise { - throw new Error('LocalStudyStore.updateSchedule: deferred to track T1'); + + async updateSchedule(entry: ScheduleEntry): Promise { + const store = this._readSchedule(); + store.entries[entry.questionId] = entry; + this._write(STUDY_STORAGE_KEYS.schedule, store); } - async getDueItems(_now: number): Promise { - throw new Error('LocalStudyStore.getDueItems: deferred to track T1'); + + /** Return questionIds whose nextDue is at or before `now`. */ + async getDueItems(now: number): Promise { + const store = this._readSchedule(); + return Object.values(store.entries) + .filter(e => e.nextDue <= now) + .map(e => e.questionId); } - // progress + + // ------------------------------------------------------------------------- + // Progress + // ------------------------------------------------------------------------- + async getProgress(): Promise { - return INITIAL_PROGRESS; + const raw = this._read(STUDY_STORAGE_KEYS.progress, null); + if (raw && raw.version === 1 && raw.progress) { + return raw.progress; + } + return structuredClone(INITIAL_PROGRESS); } - async updateProgress(_progress: UserProgress): Promise { - throw new Error('LocalStudyStore.updateProgress: deferred to track T1'); + + async updateProgress(progress: UserProgress): Promise { + const store: ProgressStore = { version: 1, progress }; + this._write(STUDY_STORAGE_KEYS.progress, store); } - // settings + + // ------------------------------------------------------------------------- + // Settings + // ------------------------------------------------------------------------- + async getSettings(): Promise { - return DEFAULT_STUDY_SETTINGS; + const raw = this._read(STUDY_STORAGE_KEYS.settings, null); + if (raw && raw.version === 1 && raw.settings) { + // Merge stored settings with defaults so new fields are never undefined. + return { ...DEFAULT_STUDY_SETTINGS, ...raw.settings }; + } + return { ...DEFAULT_STUDY_SETTINGS }; } - async updateSettings(_patch: Partial): Promise { - throw new Error('LocalStudyStore.updateSettings: deferred to track T1'); + + async updateSettings(patch: Partial): Promise { + const current = await this.getSettings(); + const updated: StudySettings = { ...current, ...patch, version: 1 }; + const store: SettingsStore = { version: 1, settings: updated }; + this._write(STUDY_STORAGE_KEYS.settings, store); } - // export/import + + // ------------------------------------------------------------------------- + // Export / import + // ------------------------------------------------------------------------- + + /** + * Serialise the entire study state to a JSON string. + * Shape: `{ version: 1, responses, schedule, progress, settings }`. + */ async exportAll(): Promise { - throw new Error('LocalStudyStore.exportAll: deferred to track T1'); + const [progressResult, settingsResult] = await Promise.all([ + this.getProgress(), + this.getSettings(), + ]); + + const responseStore = this._readResponses(); + const scheduleStore = this._readSchedule(); + + const payload: ExportPayload = { + version: 1, + responses: responseStore.responses, + schedule: scheduleStore.entries, + progress: progressResult, + settings: settingsResult, + }; + + return JSON.stringify(payload); + } + + /** + * Import a JSON string produced by `exportAll`. + * + * Merge strategy: + * - responses: appended (de-duplicated by questionId+timestamp). + * - schedule: upserted by questionId. + * - progress: overwritten if imported lastActivity is newer. + * - settings: always overwritten. + * + * Returns `{ success: true, warnings: [] }` on full success or + * `{ success: false, warnings: [...] }` when the payload is invalid. + */ + async importAll(json: string): Promise<{ success: boolean; warnings: string[] }> { + const warnings: string[] = []; + + let payload: unknown; + try { + payload = JSON.parse(json); + } catch { + return { success: false, warnings: ['Import failed: invalid JSON.'] }; + } + + if (!isExportPayload(payload)) { + return { + success: false, + warnings: ['Import failed: payload does not match expected shape (version 1).'], + }; + } + + // --- Responses: append, drop exact duplicates --- + try { + const existing = this._readResponses(); + const existingKeys = new Set( + existing.responses.map(r => `${r.questionId}::${r.timestamp}`) + ); + const toAppend = payload.responses.filter( + r => !existingKeys.has(`${r.questionId}::${r.timestamp}`) + ); + existing.responses.push(...toAppend); + if (existing.responses.length > RESPONSE_LOG_CAP) { + existing.responses.splice(0, existing.responses.length - RESPONSE_LOG_CAP); + } + this._write(STUDY_STORAGE_KEYS.responses, existing); + } catch (err) { + warnings.push(`Response import partial: ${String(err)}`); + } + + // --- Schedule: upsert by questionId --- + try { + const existing = this._readSchedule(); + for (const [qid, entry] of Object.entries(payload.schedule)) { + if (isScheduleEntry(entry)) { + existing.entries[qid] = entry; + } else { + warnings.push(`Skipped invalid schedule entry for questionId "${qid}".`); + } + } + this._write(STUDY_STORAGE_KEYS.schedule, existing); + } catch (err) { + warnings.push(`Schedule import partial: ${String(err)}`); + } + + // --- Progress: overwrite if imported is newer --- + try { + const current = await this.getProgress(); + if ( + isUserProgress(payload.progress) && + payload.progress.lastActivity >= current.lastActivity + ) { + await this.updateProgress(payload.progress); + } else if (!isUserProgress(payload.progress)) { + warnings.push('Progress not imported: invalid shape.'); + } + } catch (err) { + warnings.push(`Progress import failed: ${String(err)}`); + } + + // --- Settings: always overwrite --- + try { + if (isStudySettings(payload.settings)) { + await this.updateSettings(payload.settings); + } else { + warnings.push('Settings not imported: invalid shape.'); + } + } catch (err) { + warnings.push(`Settings import failed: ${String(err)}`); + } + + return { success: true, warnings }; } - async importAll(_json: string): Promise<{ success: boolean; warnings: string[] }> { - throw new Error('LocalStudyStore.importAll: deferred to track T1'); + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private _readResponses(): ResponseStore { + return this._read(STUDY_STORAGE_KEYS.responses, { + version: 1, + responses: [], + }); + } + + private _readSchedule(): ScheduleStore { + return this._read(STUDY_STORAGE_KEYS.schedule, { + version: 1, + entries: {}, + }); + } + + private _read(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + return raw !== null ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } + } + + private _write(key: string, value: unknown): void { + localStorage.setItem(key, JSON.stringify(value)); } } +// --------------------------------------------------------------------------- +// Type guards for import validation +// --------------------------------------------------------------------------- + +function isExportPayload(v: unknown): v is ExportPayload { + if (typeof v !== 'object' || v === null) return false; + const p = v as Record; + return ( + p['version'] === 1 && + Array.isArray(p['responses']) && + typeof p['schedule'] === 'object' && + p['schedule'] !== null && + typeof p['progress'] === 'object' && + p['progress'] !== null && + typeof p['settings'] === 'object' && + p['settings'] !== null + ); +} + +function isScheduleEntry(v: unknown): v is ScheduleEntry { + if (typeof v !== 'object' || v === null) return false; + const e = v as Record; + return ( + typeof e['questionId'] === 'string' && + typeof e['nextDue'] === 'number' && + typeof e['intervalDays'] === 'number' && + typeof e['easeFactor'] === 'number' && + typeof e['repetitions'] === 'number' && + typeof e['lapses'] === 'number' && + typeof e['lastReviewed'] === 'number' && + typeof e['totalReviews'] === 'number' + ); +} + +function isUserProgress(v: unknown): v is UserProgress { + if (typeof v !== 'object' || v === null) return false; + const p = v as Record; + return ( + typeof p['totalQuizzes'] === 'number' && + typeof p['totalCorrect'] === 'number' && + typeof p['totalAttempted'] === 'number' && + typeof p['lastActivity'] === 'number' && + typeof p['completedTopics'] === 'object' && + p['completedTopics'] !== null && + typeof p['bestScores'] === 'object' && + p['bestScores'] !== null + ); +} + +function isStudySettings(v: unknown): v is StudySettings { + if (typeof v !== 'object' || v === null) return false; + const s = v as Record; + return ( + s['version'] === 1 && + typeof s['showRationaleOnSubmit'] === 'boolean' && + typeof s['requireConfidence'] === 'boolean' && + typeof s['preferredQuestionCount'] === 'number' && + typeof s['reviewMixRatio'] === 'number' + ); +} + +// --------------------------------------------------------------------------- +// Singleton factory +// --------------------------------------------------------------------------- + let _singleton: LocalStudyStore | null = null; -/** Lazy singleton. Track T1 may replace with a more sophisticated factory. */ + +/** Lazy singleton. Call this from hooks rather than constructing directly. */ export function getStudyStore(): StudyStore { if (_singleton === null) _singleton = new LocalStudyStore(); return _singleton; diff --git a/src/lib/quiz/store/migrations.test.ts b/src/lib/quiz/store/migrations.test.ts new file mode 100644 index 0000000..1f3d78b --- /dev/null +++ b/src/lib/quiz/store/migrations.test.ts @@ -0,0 +1,249 @@ +/** + * Tests for localStorage migrations. + * + * Key invariants: + * - Migrations are idempotent (safe to run twice). + * - Existing `gemmology-quiz-progress` data is preserved. + * - Version sentinel is updated to CURRENT_VERSION after migration. + * - If no legacy data exists, fresh defaults are written. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + runMigrations, + MIGRATION_VERSION_KEY, + CURRENT_VERSION, +} from './migrations'; +import { STUDY_STORAGE_KEYS } from '../study-types'; +import { INITIAL_PROGRESS } from '../question-types'; +import type { UserProgress } from '../question-types'; + +// --------------------------------------------------------------------------- +// localStorage mock +// --------------------------------------------------------------------------- + +class LocalStorageMock implements Storage { + private store: Map = new Map(); + get length() { return this.store.size; } + clear() { this.store.clear(); } + getItem(key: string) { return this.store.get(key) ?? null; } + setItem(key: string, value: string) { this.store.set(key, value); } + removeItem(key: string) { this.store.delete(key); } + key(index: number) { return [...this.store.keys()][index] ?? null; } +} + +const LEGACY_PROGRESS_KEY = 'gemmology-quiz-progress'; + +let storage: LocalStorageMock; +beforeEach(() => { + storage = new LocalStorageMock(); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function readProgressFromStorage(): { version: number; progress: UserProgress } | null { + const raw = storage.getItem(STUDY_STORAGE_KEYS.progress); + if (!raw) return null; + return JSON.parse(raw); +} + +// --------------------------------------------------------------------------- +// Version bookkeeping +// --------------------------------------------------------------------------- + +describe('version bookkeeping', () => { + it('sets MIGRATION_VERSION_KEY to CURRENT_VERSION after migration', () => { + runMigrations(storage); + expect(storage.getItem(MIGRATION_VERSION_KEY)).toBe(String(CURRENT_VERSION)); + }); + + it('CURRENT_VERSION is 1', () => { + expect(CURRENT_VERSION).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// v0 → v1 (fresh storage — no legacy data) +// --------------------------------------------------------------------------- + +describe('v0 → v1 migration (fresh storage)', () => { + it('writes a versioned progress envelope with INITIAL_PROGRESS defaults', () => { + runMigrations(storage); + const stored = readProgressFromStorage(); + expect(stored).not.toBeNull(); + expect(stored!.version).toBe(1); + expect(stored!.progress.totalQuizzes).toBe(0); + expect(stored!.progress.totalCorrect).toBe(0); + expect(stored!.progress.totalAttempted).toBe(0); + }); + + it('does not write responses or schedule keys', () => { + runMigrations(storage); + expect(storage.getItem(STUDY_STORAGE_KEYS.responses)).toBeNull(); + expect(storage.getItem(STUDY_STORAGE_KEYS.schedule)).toBeNull(); + expect(storage.getItem(STUDY_STORAGE_KEYS.settings)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// v0 → v1 (with legacy gemmology-quiz-progress data) +// --------------------------------------------------------------------------- + +describe('v0 → v1 migration (legacy data preserved)', () => { + it('lifts legacy progress into new envelope', () => { + const legacyProgress: UserProgress = { + ...INITIAL_PROGRESS, + totalQuizzes: 7, + totalCorrect: 42, + totalAttempted: 60, + lastActivity: 1_700_000_000_000, + }; + storage.setItem(LEGACY_PROGRESS_KEY, JSON.stringify(legacyProgress)); + + runMigrations(storage); + + const stored = readProgressFromStorage(); + expect(stored).not.toBeNull(); + expect(stored!.version).toBe(1); + expect(stored!.progress.totalQuizzes).toBe(7); + expect(stored!.progress.totalCorrect).toBe(42); + expect(stored!.progress.totalAttempted).toBe(60); + expect(stored!.progress.lastActivity).toBe(1_700_000_000_000); + }); + + it('merges partial legacy data with INITIAL_PROGRESS defaults', () => { + // Legacy object missing some fields (e.g. bestScores). + const partial = { totalQuizzes: 3 }; + storage.setItem(LEGACY_PROGRESS_KEY, JSON.stringify(partial)); + + runMigrations(storage); + + const stored = readProgressFromStorage(); + expect(stored!.progress.totalQuizzes).toBe(3); + // Fields not in partial should default. + expect(stored!.progress.totalCorrect).toBe(0); + expect(typeof stored!.progress.bestScores).toBe('object'); + }); + + it('preserves completedTopics from legacy data', () => { + const legacyProgress: UserProgress = { + ...INITIAL_PROGRESS, + totalQuizzes: 1, + completedTopics: { + ...INITIAL_PROGRESS.completedTopics, + fundamentals: ['crystal-systems', 'optical-properties'], + }, + }; + storage.setItem(LEGACY_PROGRESS_KEY, JSON.stringify(legacyProgress)); + + runMigrations(storage); + + const stored = readProgressFromStorage(); + expect(stored!.progress.completedTopics.fundamentals).toEqual([ + 'crystal-systems', + 'optical-properties', + ]); + }); + + it('preserves bestScores from legacy data', () => { + const legacyProgress: UserProgress = { + ...INITIAL_PROGRESS, + totalQuizzes: 1, + bestScores: { + ...INITIAL_PROGRESS.bestScores, + fundamentals: 85, + species: 70, + }, + }; + storage.setItem(LEGACY_PROGRESS_KEY, JSON.stringify(legacyProgress)); + + runMigrations(storage); + + const stored = readProgressFromStorage(); + expect(stored!.progress.bestScores.fundamentals).toBe(85); + expect(stored!.progress.bestScores.species).toBe(70); + }); + + it('ignores corrupted legacy progress JSON', () => { + storage.setItem(LEGACY_PROGRESS_KEY, 'CORRUPT{{{'); + runMigrations(storage); + // Should fall back to defaults without throwing. + const stored = readProgressFromStorage(); + expect(stored!.progress.totalQuizzes).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Idempotency +// --------------------------------------------------------------------------- + +describe('idempotency', () => { + it('is safe to run twice on fresh storage (no legacy)', () => { + runMigrations(storage); + runMigrations(storage); + const stored = readProgressFromStorage(); + expect(stored!.version).toBe(1); + expect(stored!.progress.totalQuizzes).toBe(0); + expect(storage.getItem(MIGRATION_VERSION_KEY)).toBe(String(CURRENT_VERSION)); + }); + + it('does not overwrite migrated data on second run', () => { + const legacyProgress: UserProgress = { + ...INITIAL_PROGRESS, + totalQuizzes: 5, + }; + storage.setItem(LEGACY_PROGRESS_KEY, JSON.stringify(legacyProgress)); + + runMigrations(storage); + + // Manually mutate the stored progress to simulate user activity. + const stored = readProgressFromStorage()!; + stored.progress.totalQuizzes = 99; + storage.setItem(STUDY_STORAGE_KEYS.progress, JSON.stringify(stored)); + + // Running migrations again must not reset totalQuizzes to 5. + runMigrations(storage); + + const final = readProgressFromStorage(); + expect(final!.progress.totalQuizzes).toBe(99); + }); + + it('skips migration entirely if already at CURRENT_VERSION', () => { + // Pre-populate version sentinel. + storage.setItem(MIGRATION_VERSION_KEY, String(CURRENT_VERSION)); + // Set a known progress state. + storage.setItem( + STUDY_STORAGE_KEYS.progress, + JSON.stringify({ version: 1, progress: { ...INITIAL_PROGRESS, totalQuizzes: 42 } }) + ); + + runMigrations(storage); + + // Progress should be untouched. + const stored = readProgressFromStorage(); + expect(stored!.progress.totalQuizzes).toBe(42); + }); +}); + +// --------------------------------------------------------------------------- +// Version guard edge cases +// --------------------------------------------------------------------------- + +describe('version guard edge cases', () => { + it('treats NaN version as 0 and runs migrations', () => { + storage.setItem(MIGRATION_VERSION_KEY, 'not-a-number'); + runMigrations(storage); + // Should have run the v0→v1 migration. + expect(storage.getItem(MIGRATION_VERSION_KEY)).toBe(String(CURRENT_VERSION)); + const stored = readProgressFromStorage(); + expect(stored!.version).toBe(1); + }); + + it('treats negative version as 0 and runs migrations', () => { + storage.setItem(MIGRATION_VERSION_KEY, '-99'); + runMigrations(storage); + expect(storage.getItem(MIGRATION_VERSION_KEY)).toBe(String(CURRENT_VERSION)); + }); +}); diff --git a/src/lib/quiz/store/migrations.ts b/src/lib/quiz/store/migrations.ts new file mode 100644 index 0000000..99c4d68 --- /dev/null +++ b/src/lib/quiz/store/migrations.ts @@ -0,0 +1,154 @@ +/** + * localStorage migrations for the study system. + * + * Versioning key: `gemmology-study-version` (not in STUDY_STORAGE_KEYS because + * it is meta-infrastructure, not data). + * + * Rules: + * - Each migration[i] takes the raw Storage object and upgrades from version i + * to version i+1. + * - Migrations are idempotent: running them a second time on already-migrated + * data leaves storage unchanged. + * - Only migrations[v0→v1] is needed at launch; extend the array as new + * schema versions land. + * + * See V1-PLAN.md §8. + */ + +import type { UserProgress } from '../question-types'; +import { INITIAL_PROGRESS } from '../question-types'; +import { STUDY_STORAGE_KEYS } from '../study-types'; + +/** The migration version key — not a study data key, so not in STUDY_STORAGE_KEYS. */ +export const MIGRATION_VERSION_KEY = 'gemmology-study-version'; + +/** Increment this when a new migration is added. */ +export const CURRENT_VERSION = 1; + +/** + * Migration function signature. + * Receives the raw Storage interface (supports real `localStorage` and test mocks). + */ +type Migration = (storage: Storage) => void; + +/** + * Ordered list of migrations. + * `migrations[i]` upgrades version i → i+1. + */ +const migrations: Migration[] = [ + migrationV0toV1, +]; + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/** + * Run all pending migrations against `storage`. + * Idempotent — safe to call on every page load. + */ +export function runMigrations(storage: Storage): void { + const stored = storage.getItem(MIGRATION_VERSION_KEY); + let version = stored !== null ? parseInt(stored, 10) : 0; + + // Guard against NaN or negative numbers from corrupted storage. + if (!Number.isFinite(version) || version < 0) { + version = 0; + } + + for (let i = version; i < CURRENT_VERSION; i++) { + migrations[i]!(storage); + } + + storage.setItem(MIGRATION_VERSION_KEY, String(CURRENT_VERSION)); +} + +// --------------------------------------------------------------------------- +// Migration implementations +// --------------------------------------------------------------------------- + +/** + * v0 → v1 + * + * Pre-v1 the prototype stored user progress at `gemmology-quiz-progress` as a + * bare `UserProgress` JSON object (no envelope). The v1 store wraps it in a + * versioned envelope at `gemmology-study-progress`. + * + * Steps: + * 1. If `gemmology-study-progress` already exists with version:1 — skip (idempotent). + * 2. Read `gemmology-quiz-progress`; if present and parseable, lift it into the + * new envelope and write to `gemmology-study-progress`. + * 3. If `gemmology-quiz-progress` is absent or unparseable, write fresh defaults. + * + * No other keys existed in v0, so responses/schedule/settings get their + * default values on first access (handled by LocalStudyStore._read fallbacks). + */ +function migrationV0toV1(storage: Storage): void { + // Idempotency check: if destination already exists with version 1, skip. + const existing = storage.getItem(STUDY_STORAGE_KEYS.progress); + if (existing !== null) { + try { + const parsed = JSON.parse(existing) as Record; + if (parsed['version'] === 1) { + return; // Already migrated. + } + } catch { + // Corrupted — overwrite below. + } + } + + // Attempt to lift old progress data. + const LEGACY_PROGRESS_KEY = 'gemmology-quiz-progress'; + let migratedProgress: UserProgress = structuredClone(INITIAL_PROGRESS); + + const legacyRaw = storage.getItem(LEGACY_PROGRESS_KEY); + if (legacyRaw !== null) { + try { + const parsed = JSON.parse(legacyRaw) as unknown; + if (isLegacyProgress(parsed)) { + // Merge with INITIAL_PROGRESS so any new fields added later are present. + migratedProgress = { + ...structuredClone(INITIAL_PROGRESS), + ...parsed, + // Ensure nested objects are merged, not replaced, if they exist. + completedTopics: { + ...structuredClone(INITIAL_PROGRESS.completedTopics), + ...(typeof parsed.completedTopics === 'object' && parsed.completedTopics !== null + ? (parsed.completedTopics as Record) + : {}), + }, + bestScores: { + ...structuredClone(INITIAL_PROGRESS.bestScores), + ...(typeof parsed.bestScores === 'object' && parsed.bestScores !== null + ? (parsed.bestScores as Record) + : {}), + }, + }; + } + } catch { + // Ignore parse errors; fall through to defaults. + } + } + + // Write v1 envelope. + storage.setItem( + STUDY_STORAGE_KEYS.progress, + JSON.stringify({ version: 1, progress: migratedProgress }) + ); +} + +// --------------------------------------------------------------------------- +// Internal type guard +// --------------------------------------------------------------------------- + +/** + * Minimal guard for the legacy `UserProgress` shape (v0, no version field). + * We accept partial objects and fill missing fields with INITIAL_PROGRESS + * during migration. + */ +function isLegacyProgress(v: unknown): v is Partial { + if (typeof v !== 'object' || v === null) return false; + const p = v as Record; + // At minimum we expect totalQuizzes to exist as a number for a real progress object. + return typeof p['totalQuizzes'] === 'number'; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8490ae6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary'], + include: [ + 'src/lib/quiz/store/**', + 'src/hooks/useQuiz.ts', + 'src/hooks/useExam.ts', + ], + }, + }, + resolve: { + alias: { + '@': resolve(__dirname, './src'), + '@components': resolve(__dirname, './src/components'), + '@lib': resolve(__dirname, './src/lib'), + '@styles': resolve(__dirname, './src/styles'), + }, + }, +}); From a0d650f5c48de35ee161c8ad9f18c317c6a633da Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:18:49 +0700 Subject: [PATCH 03/16] =?UTF-8?q?feat(study):=20T4=20schema=20&=20tooling?= =?UTF-8?q?=20=E2=80=94=20question=20bank=20scaffolding=20and=20CI=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the complete T4 deliverables for the study system v1: - scripts/_questions-schema.mjs: standalone Zod mirror of the questionsCollection schema from src/content/config.ts, usable without the Astro runtime - scripts/new-question.mjs: idempotent stub generator (--category, --slug, --type) - scripts/validate-questions.mjs: YAML validator with per-category coverage summary - scripts/questions-coverage.mjs: markdown coverage table - scripts/generate-question-schema.mjs: emits schemas/question.json via zod-to-json-schema - scripts/validate-questions.test.mjs: drift-detection unit tests (node --test) - schemas/question.json: JSON Schema for IDE YAML autocomplete - src/content/questions/_example/example-mcq.yaml: annotated curator reference template - package.json: adds question:new, validate:questions, questions:coverage, schema:questions scripts - .github/workflows/deploy.yml: adds "Validate question bank" step (conditional on YAML presence) --- .github/workflows/deploy.yml | 4 + package.json | 6 +- schemas/question.json | 182 ++++++++++++++ scripts/_questions-schema.mjs | 128 ++++++++++ scripts/generate-question-schema.mjs | 55 ++++ scripts/new-question.mjs | 197 +++++++++++++++ scripts/questions-coverage.mjs | 123 +++++++++ scripts/validate-questions.mjs | 219 ++++++++++++++++ scripts/validate-questions.test.mjs | 234 ++++++++++++++++++ .../questions/_example/example-mcq.yaml | 140 +++++++++++ 10 files changed, 1287 insertions(+), 1 deletion(-) create mode 100644 schemas/question.json create mode 100644 scripts/_questions-schema.mjs create mode 100644 scripts/generate-question-schema.mjs create mode 100644 scripts/new-question.mjs create mode 100644 scripts/questions-coverage.mjs create mode 100644 scripts/validate-questions.mjs create mode 100644 scripts/validate-questions.test.mjs create mode 100644 src/content/questions/_example/example-mcq.yaml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9960109..85bac9d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -25,6 +25,10 @@ jobs: - name: Copy database files run: npm run copy-db + - name: Validate question bank + if: ${{ hashFiles('src/content/questions/**/*.yaml') != '' }} + run: npm run validate:questions + - name: Build run: npm run build diff --git a/package.json b/package.json index ac77709..0a275f0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,11 @@ "prerender": "node scripts/prerender-crystals.js", "check-svgs": "node scripts/audit-crystal-svgs.mjs", "prebuild": "npm run sync && npm run copy-db && npm run check-svgs", - "generate-og": "npm run build && node scripts/generate-og-image.js" + "generate-og": "npm run build && node scripts/generate-og-image.js", + "question:new": "node scripts/new-question.mjs", + "validate:questions": "node scripts/validate-questions.mjs", + "questions:coverage": "node scripts/questions-coverage.mjs", + "schema:questions": "node scripts/generate-question-schema.mjs" }, "dependencies": { "@astrojs/node": "^9.5.2", diff --git a/schemas/question.json b/schemas/question.json new file mode 100644 index 0000000..348cc76 --- /dev/null +++ b/schemas/question.json @@ -0,0 +1,182 @@ +{ + "$ref": "#/definitions/GemmologyQuestion", + "definitions": { + "GemmologyQuestion": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "stem": { + "type": "string", + "minLength": 10 + }, + "type": { + "type": "string", + "enum": [ + "mcq", + "true-false", + "fill-blank", + "matching", + "image-mcq" + ] + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1 + }, + "isCorrect": { + "type": "boolean" + }, + "rationale": { + "type": "string" + } + }, + "required": [ + "text", + "isCorrect" + ], + "additionalProperties": false + }, + "minItems": 2, + "maxItems": 5 + }, + "acceptedAnswers": { + "type": "array", + "items": { + "type": "string" + } + }, + "pairs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "left": { + "type": "string" + }, + "right": { + "type": "string" + } + }, + "required": [ + "left", + "right" + ], + "additionalProperties": false + } + }, + "rationaleCorrect": { + "type": "string" + }, + "difficulty": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "category": { + "type": "string", + "enum": [ + "fundamentals", + "equipment", + "species", + "identification", + "phenomena", + "origin", + "market", + "care" + ] + }, + "conceptTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "sourceArticle": { + "type": "string" + }, + "examRelevance": { + "type": "string", + "enum": [ + "FGA-foundation", + "FGA-diploma", + "GIA-GG" + ] + }, + "confusionPairs": { + "type": "array", + "items": { + "type": "string" + } + }, + "similarTo": { + "type": "array", + "items": { + "type": "string" + } + }, + "imageRef": { + "type": "string" + }, + "unvetted": { + "type": "boolean", + "default": false + }, + "authorReviewed": { + "type": "string" + }, + "lastReviewed": { + "type": "string", + "format": "date-time" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "references": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "citation": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "citation" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "stem", + "type", + "rationaleCorrect", + "difficulty", + "category", + "conceptTags" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Gemmology Question", + "description": "A single question in the gemmology.dev curated question bank. See src/content/questions/_example/example-mcq.yaml for a full annotated example." +} diff --git a/scripts/_questions-schema.mjs b/scripts/_questions-schema.mjs new file mode 100644 index 0000000..958eed8 --- /dev/null +++ b/scripts/_questions-schema.mjs @@ -0,0 +1,128 @@ +/** + * Standalone Zod schema for the questions content collection. + * + * This module intentionally mirrors the schema declared in + * src/content/config.ts (questionsCollection) — WITHOUT importing from Astro, + * so CI and Node scripts can validate YAML files without booting the Astro + * runtime. + * + * IMPORTANT: When src/content/config.ts is updated by the sole schema owner + * (T4 / track-lead), this file MUST be updated in lockstep. The drift- + * detection unit test at scripts/validate-questions.test.mjs enforces this + * by comparing field-by-field. + * + * Dependency: zod (transitive, available via node_modules from astro). + */ + +import { z } from 'zod'; + +// ── Option sub-schema ──────────────────────────────────────────────────────── + +export const questionOptionSchema = z.object({ + text: z.string().min(1), + isCorrect: z.boolean(), + rationale: z.string().optional(), +}); + +// ── Main question schema ───────────────────────────────────────────────────── + +export const questionSchema = z + .object({ + id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + stem: z.string().min(10), + type: z.enum(['mcq', 'true-false', 'fill-blank', 'matching', 'image-mcq']), + + // mcq / true-false / image-mcq + options: z.array(questionOptionSchema).min(2).max(5).optional(), + + // fill-blank + acceptedAnswers: z.array(z.string()).optional(), + + // matching + pairs: z + .array(z.object({ left: z.string(), right: z.string() })) + .optional(), + + // universal + rationaleCorrect: z.string(), + difficulty: z.number().int().min(1).max(5), + category: z.enum([ + 'fundamentals', + 'equipment', + 'species', + 'identification', + 'phenomena', + 'origin', + 'market', + 'care', + ]), + conceptTags: z.array(z.string()), + sourceArticle: z.string().optional(), + examRelevance: z + .enum(['FGA-foundation', 'FGA-diploma', 'GIA-GG']) + .optional(), + confusionPairs: z.array(z.string()).optional(), + similarTo: z.array(z.string()).optional(), + imageRef: z.string().optional(), + unvetted: z.boolean().default(false), + authorReviewed: z.string().optional(), + lastReviewed: z.string().datetime().optional(), + deprecated: z.boolean().default(false), + references: z + .array( + z.object({ + id: z.string(), + citation: z.string(), + url: z.string().url().optional(), + }), + ) + .optional(), + }) + .refine( + (q) => { + if (q.type === 'mcq' || q.type === 'true-false' || q.type === 'image-mcq') { + return Boolean(q.options && q.options.some((o) => o.isCorrect)); + } + if (q.type === 'fill-blank') { + return Boolean(q.acceptedAnswers && q.acceptedAnswers.length > 0); + } + if (q.type === 'matching') { + return Boolean(q.pairs && q.pairs.length >= 2); + } + return false; + }, + { message: 'Question must have valid answer data for its type' }, + ); + +/** + * Ordered list of top-level field names declared in the schema. + * Used by the drift-detection test to compare against config.ts. + */ +export const SCHEMA_FIELD_NAMES = Object.keys( + /** @type {z.ZodObject} */ (questionSchema._def.schema).shape, +); + +/** + * The valid question types, as a plain array for use in scripts. + */ +export const QUESTION_TYPES = /** @type {const} */ ([ + 'mcq', + 'true-false', + 'fill-blank', + 'matching', + 'image-mcq', +]); + +/** + * The valid categories, matching src/content/config.ts exactly. + */ +export const CATEGORIES = /** @type {const} */ ([ + 'fundamentals', + 'equipment', + 'species', + 'identification', + 'phenomena', + 'origin', + 'market', + 'care', +]); diff --git a/scripts/generate-question-schema.mjs b/scripts/generate-question-schema.mjs new file mode 100644 index 0000000..c560dbc --- /dev/null +++ b/scripts/generate-question-schema.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * generate-question-schema.mjs — emit a JSON Schema for the question YAML format. + * + * Output: schemas/question.json + * + * Used by IDEs (VS Code yaml.schemas) for autocomplete when authoring questions. + * The schema is also registered in .vscode/settings.json. + * + * Usage: + * node scripts/generate-question-schema.mjs + * npm run schema:questions + */ + +import { writeFileSync, mkdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { questionSchema } from './_questions-schema.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const OUT = resolve(ROOT, 'schemas', 'question.json'); + +const jsonSchema = zodToJsonSchema(questionSchema, { + name: 'GemmologyQuestion', + $refStrategy: 'none', // inline all refs for simpler IDE consumption + target: 'jsonSchema7', +}); + +// Inject a helpful title and description at the top level +jsonSchema.title = 'Gemmology Question'; +jsonSchema.description = + 'A single question in the gemmology.dev curated question bank. ' + + 'See src/content/questions/_example/example-mcq.yaml for a full annotated example.'; + +const out = JSON.stringify(jsonSchema, null, 2) + '\n'; +mkdirSync(resolve(ROOT, 'schemas'), { recursive: true }); +writeFileSync(OUT, out, 'utf8'); + +console.log(`Written: ${OUT}`); +console.log(''); +console.log('To enable YAML autocomplete in VS Code, add this to .vscode/settings.json:'); +console.log( + JSON.stringify( + { + 'yaml.schemas': { + './schemas/question.json': 'src/content/questions/**/*.yaml', + }, + }, + null, + 2, + ), +); diff --git a/scripts/new-question.mjs b/scripts/new-question.mjs new file mode 100644 index 0000000..1f555c0 --- /dev/null +++ b/scripts/new-question.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +/** + * new-question.mjs — scaffold a new question YAML stub. + * + * Usage: + * node scripts/new-question.mjs --category= --slug= [--type=] + * + * Options: + * --category Required. One of: fundamentals equipment species identification + * phenomena origin market care + * --slug Required. Kebab-case identifier. Becomes the question `id` field. + * Must match /^[a-z0-9][a-z0-9-]*$/. + * --type Optional. Default: mcq. One of: mcq true-false fill-blank matching image-mcq + * + * Behaviour: + * - Writes src/content/questions//.yaml + * - Refuses to overwrite an existing file (idempotent) + * - Exits 0 on success, 1 on any error + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { CATEGORIES, QUESTION_TYPES } from './_questions-schema.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); + +// ── Argument parsing ───────────────────────────────────────────────────────── + +function parseArgs(argv) { + const args = {}; + for (const arg of argv.slice(2)) { + const m = arg.match(/^--([a-z-]+)=(.+)$/); + if (m) { + args[m[1]] = m[2]; + } + } + return args; +} + +const args = parseArgs(process.argv); + +const category = args['category']; +const slug = args['slug']; +const type = args['type'] ?? 'mcq'; + +// ── Validation ──────────────────────────────────────────────────────────────── + +const errors = []; + +if (!category) { + errors.push('--category is required.'); +} else if (!CATEGORIES.includes(category)) { + errors.push(`--category="${category}" is invalid. Valid categories: ${CATEGORIES.join(', ')}`); +} + +if (!slug) { + errors.push('--slug is required.'); +} else if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) { + errors.push( + `--slug="${slug}" is invalid. Must match /^[a-z0-9][a-z0-9-]*$/ (lowercase, digits, hyphens only, no leading hyphen).`, + ); +} + +if (!QUESTION_TYPES.includes(type)) { + errors.push(`--type="${type}" is invalid. Valid types: ${QUESTION_TYPES.join(', ')}`); +} + +if (errors.length > 0) { + for (const e of errors) { + console.error(`Error: ${e}`); + } + process.exit(1); +} + +// ── Output path ─────────────────────────────────────────────────────────────── + +const outDir = resolve(ROOT, 'src', 'content', 'questions', category); +const outPath = resolve(outDir, `${slug}.yaml`); + +if (existsSync(outPath)) { + console.error(`Error: File already exists — ${outPath}`); + console.error('Refusing to overwrite. Rename or delete it first.'); + process.exit(1); +} + +// ── Stub generation ─────────────────────────────────────────────────────────── + +function buildStub(type, slug, category) { + const base = `# Question stub — fill in every placeholder marked with TODO +# Run npm run validate:questions to check this file before committing. +# See src/content/questions/_example/example-mcq.yaml for a complete example. + +id: ${slug} +stem: | + TODO: Write the question stem here. Should be scenario-based (≥ 10 chars). + For FGA-quality questions, describe an observation and ask for an identification + or interpretation — not a bare fact lookup. +type: ${type} +`; + + let typeBlock = ''; + if (type === 'mcq' || type === 'image-mcq') { + typeBlock = ` +# 3 options is the recommended default; 2–5 are accepted by the schema. +options: + - text: "TODO: Correct answer text" + isCorrect: true + rationale: "TODO: Explain WHY this is correct in diagnostic terms." + - text: "TODO: Plausible distractor 1" + isCorrect: false + rationale: "TODO: Explain why this distractor is wrong." + - text: "TODO: Plausible distractor 2" + isCorrect: false + rationale: "TODO: Explain why this distractor is wrong." +`; + if (type === 'image-mcq') { + typeBlock += `imageRef: /crystals/TODO-crystal-name.svg # path relative to /public\n`; + } + } else if (type === 'true-false') { + typeBlock = ` +options: + - text: "True" + isCorrect: true # TODO: set exactly one isCorrect: true + rationale: "TODO: Why true is correct." + - text: "False" + isCorrect: false + rationale: "TODO: Why false is incorrect." +`; + } else if (type === 'fill-blank') { + typeBlock = ` +# acceptedAnswers is case-insensitive-compared by the quiz engine. +acceptedAnswers: + - "TODO: primary accepted answer" + - "TODO: alternative phrasing (optional)" +`; + } else if (type === 'matching') { + typeBlock = ` +# At least 2 pairs required. +pairs: + - left: "TODO: term or concept" + right: "TODO: matching definition or value" + - left: "TODO: term or concept" + right: "TODO: matching definition or value" + - left: "TODO: term or concept" + right: "TODO: matching definition or value" +`; + } + + const footer = ` +rationaleCorrect: | + TODO: Explain the underlying principle or diagnostic chain that makes the + correct answer correct. Should go beyond merely restating the answer — + connect it to the broader gemmological context. + +difficulty: 3 # 1 (trivial) to 5 (expert); default 3 +category: ${category} +conceptTags: + - TODO-tag-1 + - TODO-tag-2 + +# Optional metadata — fill in where known; leave commented if unsure. +# sourceArticle: learn-slug/article-slug # Which learn article this tests +# examRelevance: FGA-foundation # FGA-foundation | FGA-diploma | GIA-GG +# confusionPairs: # ids of near-miss questions +# - other-question-id +# similarTo: # for interleaving — near-concept questions +# - other-question-id + +unvetted: true # Set to false only after FGA-qualified review +# authorReviewed: "Your name or reviewer identifier" +# lastReviewed: "2026-01-01T00:00:00Z" # ISO 8601 datetime +deprecated: false + +# references: +# - id: ref-001 +# citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann." +# url: https://example.com/optional-url +`; + + return base + typeBlock + footer; +} + +const stub = buildStub(type, slug, category); + +// ── Write ───────────────────────────────────────────────────────────────────── + +mkdirSync(outDir, { recursive: true }); +writeFileSync(outPath, stub, 'utf8'); + +console.log(`Created: ${outPath}`); +console.log(`Next steps:`); +console.log(` 1. Open the file and fill in every TODO placeholder.`); +console.log(` 2. Run: npm run validate:questions`); +console.log(` 3. Set unvetted: false after FGA-qualified review.`); diff --git a/scripts/questions-coverage.mjs b/scripts/questions-coverage.mjs new file mode 100644 index 0000000..eee4007 --- /dev/null +++ b/scripts/questions-coverage.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * questions-coverage.mjs — print a markdown coverage table for the question bank. + * + * Prints to stdout: + * | Category | Total | Vetted | Unvetted | Avg difficulty | + * |---|---|---|---|---| + * | fundamentals | 10 | 8 | 2 | 2.4 | + * ... + * + * Exits 0 always (this is a reporting tool, not a gate). + * + * Usage: + * node scripts/questions-coverage.mjs + * npm run questions:coverage + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { resolve, extname, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import yaml from 'js-yaml'; +import { questionSchema, CATEGORIES } from './_questions-schema.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const QUESTIONS_DIR = resolve(ROOT, 'src', 'content', 'questions'); + +// ── File collection (same logic as validate-questions.mjs) ──────────────────── + +function collectYamlFiles(dir) { + let results = []; + let entries; + try { + entries = readdirSync(dir); + } catch { + return results; + } + for (const entry of entries) { + if (entry.startsWith('_')) continue; + const full = resolve(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + results = results.concat(collectYamlFiles(full)); + } else if (stat.isFile() && extname(entry) === '.yaml') { + results.push(full); + } + } + return results; +} + +// ── Accumulate stats ────────────────────────────────────────────────────────── + +const stats = {}; +for (const cat of CATEGORIES) { + stats[cat] = { total: 0, vetted: 0, unvetted: 0, difficultySum: 0 }; +} + +const files = collectYamlFiles(QUESTIONS_DIR); +let parseErrors = 0; + +for (const filePath of files) { + let raw; + try { + raw = yaml.load(readFileSync(filePath, 'utf8')); + } catch { + parseErrors += 1; + continue; + } + + const result = questionSchema.safeParse(raw); + if (!result.success) continue; // skip invalid files; validate-questions reports them + + const q = result.data; + const cat = q.category; + if (!stats[cat]) continue; + + stats[cat].total += 1; + if (q.unvetted) { + stats[cat].unvetted += 1; + } else { + stats[cat].vetted += 1; + } + stats[cat].difficultySum += q.difficulty; +} + +// ── Render markdown table ───────────────────────────────────────────────────── + +const rows = CATEGORIES.map((cat) => { + const s = stats[cat]; + const avgDiff = s.total > 0 ? (s.difficultySum / s.total).toFixed(1) : '-'; + return [cat, String(s.total), String(s.vetted), String(s.unvetted), avgDiff]; +}); + +const headers = ['Category', 'Total', 'Vetted', 'Unvetted', 'Avg difficulty']; + +// Compute column widths +const widths = headers.map((h, i) => + Math.max(h.length, ...rows.map((r) => r[i].length)), +); + +function pad(s, w) { + return s.padEnd(w); +} + +function row(cells) { + return '| ' + cells.map((c, i) => pad(c, widths[i])).join(' | ') + ' |'; +} + +function sep() { + return '| ' + widths.map((w) => '-'.repeat(w)).join(' | ') + ' |'; +} + +console.log(row(headers)); +console.log(sep()); +for (const r of rows) { + console.log(row(r)); +} + +if (parseErrors > 0) { + console.error(`\nWarning: ${parseErrors} file(s) could not be parsed and were excluded.`); + console.error('Run npm run validate:questions for details.'); +} diff --git a/scripts/validate-questions.mjs b/scripts/validate-questions.mjs new file mode 100644 index 0000000..f22bdab --- /dev/null +++ b/scripts/validate-questions.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +/** + * validate-questions.mjs — validate every YAML under src/content/questions/** + * against the published Zod schema and print a per-category coverage summary. + * + * Exit codes: + * 0 — all files valid (including the case of zero files found) + * 1 — one or more files failed validation, or a parse/read error occurred + * + * Usage: + * node scripts/validate-questions.mjs + * npm run validate:questions + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { resolve, relative, extname, basename, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import yaml from 'js-yaml'; +import { questionSchema, CATEGORIES } from './_questions-schema.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); +const QUESTIONS_DIR = resolve(ROOT, 'src', 'content', 'questions'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Recursively collect all .yaml files under a directory, excluding paths + * whose components start with '_' (Astro glob-loader convention). + */ +function collectYamlFiles(dir) { + let results = []; + let entries; + try { + entries = readdirSync(dir); + } catch { + return results; // directory doesn't exist — zero questions, valid state + } + + for (const entry of entries) { + if (entry.startsWith('_')) continue; // skip _example, _schema, etc. + const full = resolve(dir, entry); + const stat = statSync(full); + if (stat.isDirectory()) { + results = results.concat(collectYamlFiles(full)); + } else if (stat.isFile() && extname(entry) === '.yaml') { + results.push(full); + } + } + return results; +} + +/** + * Format a Zod error into a human-readable string. + */ +function formatZodError(zodError) { + return zodError.errors + .map((e) => { + const path = e.path.length > 0 ? e.path.join('.') : '(root)'; + return ` [${path}] ${e.message}`; + }) + .join('\n'); +} + +// ── Stats per category ──────────────────────────────────────────────────────── + +const stats = {}; +for (const cat of CATEGORIES) { + stats[cat] = { + total: 0, + vetted: 0, + unvetted: 0, + missingConfusionPairs: 0, + missingReferences: 0, + difficultySum: 0, + }; +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +const files = collectYamlFiles(QUESTIONS_DIR); + +if (files.length === 0) { + console.log('No question files found. Validation passed (nothing to validate).'); + printCoverageSummary(); + process.exit(0); +} + +let hasErrors = false; +let validCount = 0; + +for (const filePath of files) { + const rel = relative(ROOT, filePath); + let raw; + + // Parse YAML + try { + const src = readFileSync(filePath, 'utf8'); + raw = yaml.load(src); + } catch (err) { + console.error(`FAIL ${rel}`); + console.error(` YAML parse error: ${err.message}`); + hasErrors = true; + continue; + } + + // Validate with Zod + const result = questionSchema.safeParse(raw); + if (!result.success) { + console.error(`FAIL ${rel}`); + console.error(formatZodError(result.error)); + hasErrors = true; + continue; + } + + // Accumulate stats + const q = result.data; + const cat = q.category; + if (stats[cat]) { + stats[cat].total += 1; + if (q.unvetted) { + stats[cat].unvetted += 1; + } else { + stats[cat].vetted += 1; + } + if (!q.confusionPairs || q.confusionPairs.length === 0) { + stats[cat].missingConfusionPairs += 1; + } + if (!q.references || q.references.length === 0) { + stats[cat].missingReferences += 1; + } + stats[cat].difficultySum += q.difficulty; + } + + console.log(`ok ${rel}`); + validCount += 1; +} + +console.log(''); +console.log(`Validated ${validCount}/${files.length} files successfully.`); + +printCoverageSummary(); + +process.exit(hasErrors ? 1 : 0); + +// ── Coverage summary ────────────────────────────────────────────────────────── + +function printCoverageSummary() { + console.log(''); + console.log('Per-category coverage:'); + console.log(''); + + // Build rows + const rows = CATEGORIES.map((cat) => { + const s = stats[cat]; + const avgDiff = + s.total > 0 ? (s.difficultySum / s.total).toFixed(1) : '-'; + return { + category: cat, + total: s.total, + vetted: s.vetted, + unvetted: s.unvetted, + noConfusion: s.missingConfusionPairs, + noRefs: s.missingReferences, + avgDiff, + }; + }); + + // Column widths + const cols = { + category: Math.max('category'.length, ...rows.map((r) => r.category.length)), + total: Math.max('total'.length, ...rows.map((r) => String(r.total).length)), + vetted: Math.max('vetted'.length, ...rows.map((r) => String(r.vetted).length)), + unvetted: Math.max('unvetted'.length, ...rows.map((r) => String(r.unvetted).length)), + noConfusion: Math.max('no confusionPairs'.length, ...rows.map((r) => String(r.noConfusion).length)), + noRefs: Math.max('no references'.length, ...rows.map((r) => String(r.noRefs).length)), + avgDiff: Math.max('avg difficulty'.length, ...rows.map((r) => String(r.avgDiff).length)), + }; + + function pad(s, w) { + return String(s).padEnd(w); + } + + const header = + `| ${pad('category', cols.category)} ` + + `| ${pad('total', cols.total)} ` + + `| ${pad('vetted', cols.vetted)} ` + + `| ${pad('unvetted', cols.unvetted)} ` + + `| ${pad('no confusionPairs', cols.noConfusion)} ` + + `| ${pad('no references', cols.noRefs)} ` + + `| ${pad('avg difficulty', cols.avgDiff)} |`; + + const sep = + `|-${'-'.repeat(cols.category)}-` + + `|-${'-'.repeat(cols.total)}-` + + `|-${'-'.repeat(cols.vetted)}-` + + `|-${'-'.repeat(cols.unvetted)}-` + + `|-${'-'.repeat(cols.noConfusion)}-` + + `|-${'-'.repeat(cols.noRefs)}-` + + `|-${'-'.repeat(cols.avgDiff)}-|`; + + console.log(header); + console.log(sep); + + for (const r of rows) { + const line = + `| ${pad(r.category, cols.category)} ` + + `| ${pad(r.total, cols.total)} ` + + `| ${pad(r.vetted, cols.vetted)} ` + + `| ${pad(r.unvetted, cols.unvetted)} ` + + `| ${pad(r.noConfusion, cols.noConfusion)} ` + + `| ${pad(r.noRefs, cols.noRefs)} ` + + `| ${pad(r.avgDiff, cols.avgDiff)} |`; + console.log(line); + } + + console.log(''); +} diff --git a/scripts/validate-questions.test.mjs b/scripts/validate-questions.test.mjs new file mode 100644 index 0000000..3e57798 --- /dev/null +++ b/scripts/validate-questions.test.mjs @@ -0,0 +1,234 @@ +/** + * validate-questions.test.mjs — drift-detection unit test. + * + * Verifies that the standalone Zod schema in scripts/_questions-schema.mjs + * declares exactly the same top-level fields as the published schema in + * src/content/config.ts (questionsCollection). + * + * Run with: + * node --test scripts/validate-questions.test.mjs + * + * If this test fails it means the two schemas have diverged. Update + * scripts/_questions-schema.mjs to match src/content/config.ts. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { SCHEMA_FIELD_NAMES, questionSchema, CATEGORIES, QUESTION_TYPES } from './_questions-schema.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); + +// ── Parse field names from config.ts source text ─────────────────────────── +// Rather than importing from Astro (which requires the Astro runtime), we +// extract the field names from the TypeScript source with a regex targeting +// the exact indentation used in questionsCollection's z.object() block. +// +// In config.ts the schema is formatted as: +// const questionsCollection = defineCollection({ +// type: 'data', +// schema: z +// .object({ +// id: z.string()... <-- 6-space indent +// stem: z.string()... <-- 6-space indent +// ... +// }) +// .refine(...) +// }); +// +// We locate the questionsCollection block, then collect all lines that match +// exactly the 6-space indented `fieldName:` pattern before the closing `})`. + +function extractFieldNamesFromConfigTs() { + const src = readFileSync(resolve(ROOT, 'src', 'content', 'config.ts'), 'utf8'); + + // Find the questionsCollection block + const qStart = src.indexOf('const questionsCollection'); + if (qStart === -1) throw new Error('Could not find questionsCollection in config.ts'); + + // Find the closing of questionsCollection (next top-level const or end of file) + const qEnd = src.indexOf('\nconst ', qStart + 1); + const block = qEnd === -1 ? src.slice(qStart) : src.slice(qStart, qEnd); + + // Extract top-level field names: exactly 6 spaces + identifier + colon + // (This is the indentation level inside z.object({...}) in config.ts) + const fieldRegex = /^ ([a-zA-Z][a-zA-Z0-9]*)\s*:/gm; + const fields = []; + let m; + while ((m = fieldRegex.exec(block)) !== null) { + fields.push(m[1]); + } + + return fields; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +test('script schema has same fields as config.ts questionsCollection', () => { + const configFields = extractFieldNamesFromConfigTs(); + + // The script schema exposes SCHEMA_FIELD_NAMES from _questions-schema.mjs + const scriptFields = SCHEMA_FIELD_NAMES; + + // Check same set of fields (order may vary) + const configSet = new Set(configFields); + const scriptSet = new Set(scriptFields); + + const missingInScript = [...configSet].filter((f) => !scriptSet.has(f)); + const extraInScript = [...scriptSet].filter((f) => !configSet.has(f)); + + assert.deepEqual( + missingInScript, + [], + `Fields in config.ts but missing from _questions-schema.mjs: ${missingInScript.join(', ')}`, + ); + + assert.deepEqual( + extraInScript, + [], + `Fields in _questions-schema.mjs but not in config.ts: ${extraInScript.join(', ')}`, + ); +}); + +test('CATEGORIES matches the category enum in config.ts', () => { + const src = readFileSync( + resolve(ROOT, 'src', 'content', 'config.ts'), + 'utf8', + ); + + // Extract categories from the category enum inside questionsCollection + const questionsCollectionStart = src.indexOf('const questionsCollection'); + const block = src.slice(questionsCollectionStart); + const catEnumMatch = block.match(/category: z\.enum\(\[([\s\S]*?)\]\)/); + assert.ok(catEnumMatch, 'Could not find category enum in questionsCollection'); + + const configCats = catEnumMatch[1] + .split(',') + .map((s) => s.trim().replace(/['"]/g, '')) + .filter(Boolean); + + assert.deepEqual( + [...CATEGORIES].sort(), + configCats.sort(), + 'CATEGORIES in _questions-schema.mjs do not match config.ts', + ); +}); + +test('QUESTION_TYPES matches the type enum in config.ts', () => { + const src = readFileSync( + resolve(ROOT, 'src', 'content', 'config.ts'), + 'utf8', + ); + + const questionsCollectionStart = src.indexOf('const questionsCollection'); + const block = src.slice(questionsCollectionStart); + const typeEnumMatch = block.match(/type: z\.enum\(\[([\s\S]*?)\]\)/); + assert.ok(typeEnumMatch, 'Could not find type enum in questionsCollection'); + + const configTypes = typeEnumMatch[1] + .split(',') + .map((s) => s.trim().replace(/['"]/g, '')) + .filter(Boolean); + + assert.deepEqual( + [...QUESTION_TYPES].sort(), + configTypes.sort(), + 'QUESTION_TYPES in _questions-schema.mjs do not match config.ts', + ); +}); + +test('script schema rejects a question with no correct option (mcq)', () => { + const result = questionSchema.safeParse({ + id: 'test-001', + stem: 'A red stone shows RI 1.762 to 1.770. What is it?', + type: 'mcq', + options: [ + { text: 'Ruby', isCorrect: false }, + { text: 'Spinel', isCorrect: false }, + ], + rationaleCorrect: 'This is always ruby.', + difficulty: 3, + category: 'species', + conceptTags: ['ri'], + }); + assert.ok(!result.success, 'Should fail when no option is correct'); +}); + +test('script schema accepts a minimal valid mcq', () => { + const result = questionSchema.safeParse({ + id: 'test-valid-001', + stem: 'A red stone shows RI 1.762 to 1.770 and SG 4.00. What is it?', + type: 'mcq', + options: [ + { text: 'Ruby', isCorrect: true, rationale: 'Correct.' }, + { text: 'Spinel', isCorrect: false, rationale: 'Spinel is isotropic.' }, + ], + rationaleCorrect: 'Ruby diagnostic chain: optic character, then SG.', + difficulty: 3, + category: 'species', + conceptTags: ['ri', 'sg'], + }); + assert.ok(result.success, `Should pass: ${JSON.stringify(result.error?.errors)}`); +}); + +test('script schema accepts a minimal valid fill-blank', () => { + const result = questionSchema.safeParse({ + id: 'fill-001', + stem: 'The refractive index of corundum ranges from 1.762 to ___.', + type: 'fill-blank', + acceptedAnswers: ['1.770', '1.77'], + rationaleCorrect: 'Corundum RI is 1.762–1.770.', + difficulty: 2, + category: 'fundamentals', + conceptTags: ['ri', 'corundum'], + }); + assert.ok(result.success, `Should pass: ${JSON.stringify(result.error?.errors)}`); +}); + +test('script schema accepts a minimal valid matching question', () => { + const result = questionSchema.safeParse({ + id: 'match-001', + stem: 'Match each gem species to its crystal system.', + type: 'matching', + pairs: [ + { left: 'Diamond', right: 'Cubic' }, + { left: 'Quartz', right: 'Trigonal' }, + ], + rationaleCorrect: 'Crystal systems are fundamental for optic character.', + difficulty: 2, + category: 'fundamentals', + conceptTags: ['crystal-systems'], + }); + assert.ok(result.success, `Should pass: ${JSON.stringify(result.error?.errors)}`); +}); + +test('script schema rejects fill-blank with no acceptedAnswers', () => { + const result = questionSchema.safeParse({ + id: 'fill-bad-001', + stem: 'The refractive index of corundum ranges from 1.762 to ___.', + type: 'fill-blank', + rationaleCorrect: 'Corundum RI is 1.762–1.770.', + difficulty: 2, + category: 'fundamentals', + conceptTags: ['ri'], + }); + assert.ok(!result.success, 'fill-blank without acceptedAnswers should fail'); +}); + +test('script schema rejects id with uppercase letters', () => { + const result = questionSchema.safeParse({ + id: 'Invalid-ID', + stem: 'A red stone shows RI 1.762 to 1.770 and SG 4.00. What is it?', + type: 'mcq', + options: [{ text: 'Ruby', isCorrect: true }], + rationaleCorrect: 'Ruby.', + difficulty: 3, + category: 'species', + conceptTags: ['ri'], + }); + assert.ok(!result.success, 'Uppercase id should fail'); +}); diff --git a/src/content/questions/_example/example-mcq.yaml b/src/content/questions/_example/example-mcq.yaml new file mode 100644 index 0000000..bdd573b --- /dev/null +++ b/src/content/questions/_example/example-mcq.yaml @@ -0,0 +1,140 @@ +# ────────────────────────────────────────────────────────────────────────────── +# EXAMPLE QUESTION — for curator reference and scaffolder template only. +# +# This file lives in _example/ so Astro's glob-loader ignores it (the loader +# pattern excludes paths starting with _). It is NOT loaded into the question +# bank. Run `npx astro sync` to confirm no schema errors. +# +# To create a real question, run: +# npm run question:new -- --category= --slug= +# +# See docs/authoring-questions.md for the full curator guide. +# ────────────────────────────────────────────────────────────────────────────── + +# ── Required fields ───────────────────────────────────────────────────────── + +# id: must match the filename slug, lowercase, digits and hyphens only, +# no leading hyphen. Regex: /^[a-z0-9][a-z0-9-]*$/ +id: example-mcq-001 + +# stem: the question text. Use scenario-based phrasing, not bare fact lookup. +# Minimum 10 characters. Multi-line YAML literal block (|) is fine. +stem: | + A polished red stone gives the following lab readings: + RI 1.762–1.770, SG 4.00, uniaxial negative on the polariscope, + strong red fluorescence under LWUV. + What is the most likely identification? + +# type: one of mcq | true-false | fill-blank | matching | image-mcq +type: mcq + +# ── Type-specific answer data ───────────────────────────────────────────────── + +# For mcq / true-false / image-mcq: +# options: list of 2–5 answer choices; exactly one must have isCorrect: true. +# Recommended default: 3 options. "All of the above" style allows up to 5. +# rationale per option is optional but strongly encouraged for curated items. +options: + - text: "Natural ruby (corundum)" + isCorrect: true + rationale: | + All four readings are diagnostic of corundum: RI 1.762–1.770 (within + corundum's published range 1.762–1.770), SG 4.00, uniaxial negative + optic character, and strong red LWUV fluorescence are textbook ruby. + - text: "Spinel" + isCorrect: false + rationale: | + Spinel is isotropic (single RI ≈ 1.718) and cannot produce a uniaxial + reading. Burma spinel does fluoresce red under LWUV but the RI range + and the optic character exclude it conclusively. + - text: "Pyrope garnet" + isCorrect: false + rationale: | + Pyrope is also isotropic with RI ≈ 1.730–1.760 and SG 3.7–3.9. + There is slight RI overlap but the uniaxial polariscope reading + and SG 4.00 exclude it. + +# For fill-blank questions: +# acceptedAnswers: +# - "primary answer" +# - "alternative phrasing" + +# For matching questions: +# pairs: +# - left: "Diamond" +# right: "Cubic" +# - left: "Quartz" +# right: "Trigonal" + +# For image-mcq, also set: +# imageRef: /crystals/corundum-bipyramid.svg + +# ── Universal required fields ───────────────────────────────────────────────── + +# rationaleCorrect: explains the diagnostic chain for the correct answer. +# Should elaborate beyond merely restating the answer. +rationaleCorrect: | + This is a classic FGA practical exam scenario: three red gems with partially + overlapping properties. The diagnostic chain is optic character first + (isotropic reading eliminates spinel and pyrope, leaving only corundum as + a uniaxial negative species in the red gem range), then SG 4.00 (matches + corundum's 3.99–4.00, excludes pyrope at 3.7–3.9). + Note: fluorescence alone is never diagnostic — some Burma spinel also + fluoresces red — so it confirms but does not establish the identification. + +# difficulty: integer 1 (trivial / recall) to 5 (expert / complex reasoning) +difficulty: 3 + +# category: one of fundamentals | equipment | species | identification | +# phenomena | origin | market | care +category: species + +# conceptTags: array of lowercase hyphenated concept identifiers. +# Used for filtering, interleaving, and the coverage report. +conceptTags: + - refractive-index + - specific-gravity + - optic-character + - fluorescence + - corundum + - ruby + +# ── Optional metadata ───────────────────────────────────────────────────────── + +# sourceArticle: slug of the /learn/ article this question tests. +sourceArticle: species/corundum + +# examRelevance: one of FGA-foundation | FGA-diploma | GIA-GG +examRelevance: FGA-diploma + +# confusionPairs: ids of near-miss questions (drives interleaving). +# High-value metadata — populate whenever there is a known confusion pair. +confusionPairs: + - spinel-ri-001 + - pyrope-sg-001 + +# similarTo: ids of conceptually similar questions (for session interleaving). +similarTo: + - corundum-ri-002 + - ruby-fluorescence-001 + +# unvetted: true = auto-generated or not yet FGA-reviewed; false = expert-vetted. +# New stubs should default to true. Only set false after review. +unvetted: false + +# authorReviewed: free-text reviewer identifier (name, initials, role). +authorReviewed: "FGA-reviewer-1" + +# lastReviewed: ISO 8601 datetime string. +lastReviewed: "2026-05-15T10:00:00Z" + +# deprecated: set true to soft-delete without removing the file. +deprecated: false + +# references: cited sources supporting the rationale. +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 102–115." + url: https://www.butterworth-heinemann.com/ + - id: gia-corundum + citation: "GIA (2024). Corundum — Ruby and Sapphire. Gem Reference Guide." From f5fa7be055a98bcdbf4ed51c6615ad51f4498d5d Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:20:53 +0700 Subject: [PATCH 04/16] feat(study): implement SM-2 scheduler, due-aware selector, near-miss interleaver (T2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full SM-2 algorithm in applySM2: lapse resets repetitions/interval, recall grows interval (1→6→round(prev*EF)), EF updated unconditionally and clamped to ≥1.3. selectQuestionsV2: due-first (most-overdue first), fresh-fill per category budget, stale-fallback (excludes un-budgeted due items), round-robin category interleaving. interleaveNearMisses: greedy deterministic reorder enforcing minSpacing between confusion pairs, gracefully accepts violations when constraint is unsatisfiable. Adds vitest + @vitest/coverage-v8 as dev deps; vitest.config.ts minimal setup. --- package-lock.json | 551 +++++++++++++++++++++++++++++++++--- package.json | 4 +- src/lib/quiz/interleaver.ts | 85 +++++- src/lib/quiz/scheduler.ts | 59 +++- src/lib/quiz/selector.ts | 162 ++++++++++- vitest.config.ts | 31 ++ 6 files changed, 817 insertions(+), 75 deletions(-) create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 9ebea73..b068969 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,12 +38,14 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/three": "^0.164.0", + "@vitest/coverage-v8": "^4.1.5", "autoprefixer": "^10.4.20", "playwright": "^1.58.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.15", "tsx": "^4.0.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^4.1.5" } }, "node_modules/@alloc/quick-lru": { @@ -363,12 +365,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -449,9 +451,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -461,6 +463,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@capsizecss/unpack": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", @@ -2179,6 +2191,13 @@ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/typography": { "version": "0.5.19", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", @@ -2238,6 +2257,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2247,6 +2277,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dompurify": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", @@ -2442,6 +2479,150 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2590,6 +2771,35 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/astro": { "version": "5.16.15", "resolved": "https://registry.npmjs.org/astro/-/astro-5.16.15.tgz", @@ -3111,6 +3321,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -3796,6 +4016,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expressive-code": { "version": "0.41.6", "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.41.6.tgz", @@ -4036,6 +4266,16 @@ "uncrypto": "^0.1.3" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -4515,6 +4755,52 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports/node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/its-fine": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", @@ -4674,16 +4960,45 @@ } }, "node_modules/magicast": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", - "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -5703,6 +6018,17 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ofetch": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", @@ -5843,6 +6169,13 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -6810,6 +7143,13 @@ "@types/hast": "^3.0.4" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -6878,6 +7218,13 @@ "integrity": "sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==", "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -6919,6 +7266,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-replace-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", @@ -7002,6 +7356,19 @@ "node": ">= 6" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -7174,6 +7541,13 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -7199,6 +7573,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7330,7 +7714,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7347,7 +7730,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7364,7 +7746,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7381,7 +7762,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7398,7 +7778,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7415,7 +7794,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7432,7 +7810,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7449,7 +7826,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7466,7 +7842,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7483,7 +7858,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7500,7 +7874,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7517,7 +7890,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7534,7 +7906,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7551,7 +7922,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7568,7 +7938,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7585,7 +7954,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7602,7 +7970,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7619,7 +7986,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7636,7 +8002,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7653,7 +8018,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7670,7 +8034,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7687,7 +8050,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7704,7 +8066,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7721,7 +8082,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7738,7 +8098,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7755,7 +8114,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -7811,7 +8169,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8264,6 +8621,103 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -8309,6 +8763,23 @@ "node": ">=4" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", diff --git a/package.json b/package.json index ac77709..a6b4930 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,13 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/three": "^0.164.0", + "@vitest/coverage-v8": "^4.1.5", "autoprefixer": "^10.4.20", "playwright": "^1.58.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.15", "tsx": "^4.0.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^4.1.5" } } diff --git a/src/lib/quiz/interleaver.ts b/src/lib/quiz/interleaver.ts index de5e6da..6d1f211 100644 --- a/src/lib/quiz/interleaver.ts +++ b/src/lib/quiz/interleaver.ts @@ -1,22 +1,83 @@ /** - * Near-miss interleaver — contract. + * Near-miss interleaver — full implementation. * - * Track T2 owns the implementation. See V1-PLAN.md §5.3. + * Track T2 (`.trees/study-algorithms`) owns this file. + * + * See V1-PLAN.md §5.3. */ /** - * Re-order a list of question IDs so that items declared `confusionPairs` for - * each other are spaced ≥ N positions apart. Falls back to the input order - * if the constraint cannot be satisfied. + * Re-order a list of question IDs so that items declared as confusion pairs + * for each other are spaced ≥ `minSpacing` positions apart. + * + * Algorithm (greedy, deterministic given input order): + * Walk the list. When placing item at position i, check if it is a confusion + * partner of any item already placed within the last `minSpacing` positions. + * If so, swap it forward with the next safe candidate (earliest item in the + * not-yet-placed tail that does NOT conflict). If no safe swap exists, accept + * the violation and continue. * - * Implementation deferred to track T2. + * Pure and deterministic: same input always produces same output. + * + * V1-PLAN §5.3. */ export function interleaveNearMisses( - _ids: ReadonlyArray, - _confusionPairs: ReadonlyMap, - _minSpacing: number = 3, + ids: ReadonlyArray, + confusionPairs: ReadonlyMap, + minSpacing: number = 3, ): string[] { - throw new Error( - 'interleaveNearMisses: implementation deferred to track T2 (study-algorithms)', - ); + // Work on a mutable copy. + const result: string[] = []; + const remaining = [...ids]; + + while (remaining.length > 0) { + // Index of the current candidate (first in remaining). + let chosen = 0; + + // Check if placing remaining[chosen] here violates minSpacing with any + // already-placed item. + const candidate = remaining[chosen]; + if (causesConflict(candidate, result, confusionPairs, minSpacing)) { + // Search forward for a safe swap candidate. + let safeIdx = -1; + for (let j = 1; j < remaining.length; j++) { + if (!causesConflict(remaining[j], result, confusionPairs, minSpacing)) { + safeIdx = j; + break; + } + } + + if (safeIdx !== -1) { + chosen = safeIdx; + } + // else: accept the violation and use chosen = 0. + } + + // Remove from remaining and push to result. + const [picked] = remaining.splice(chosen, 1); + result.push(picked); + } + + return result; +} + +/** + * Returns true if placing `id` at the current tail of `placed` would put it + * within `minSpacing` positions of any of its confusion partners. + */ +function causesConflict( + id: string, + placed: string[], + confusionPairs: ReadonlyMap, + minSpacing: number, +): boolean { + const partners = confusionPairs.get(id); + if (!partners || partners.length === 0) return false; + + const partnerSet = new Set(partners); + const start = Math.max(0, placed.length - minSpacing); + for (let i = start; i < placed.length; i++) { + if (partnerSet.has(placed[i])) return true; + } + return false; } diff --git a/src/lib/quiz/scheduler.ts b/src/lib/quiz/scheduler.ts index d8a81d2..95e6cec 100644 --- a/src/lib/quiz/scheduler.ts +++ b/src/lib/quiz/scheduler.ts @@ -1,8 +1,7 @@ /** - * SM-2 scheduler — contract & shared helper. + * SM-2 scheduler — full implementation. * - * Track T2 owns the full implementation; this file only carries the public - * signatures so dependent tracks (T1, T3) can compile against the contract. + * Track T2 (`.trees/study-algorithms`) owns this file. * * See V1-PLAN.md §5.1 (SM-2 update) and §A.1. */ @@ -13,6 +12,8 @@ import type { Confidence, ScheduleEntry } from './study-types'; export const DEFAULT_EASE = 2.5; /** Lower bound for easiness factor — SM-2 paper. */ export const MIN_EASE = 1.3; +/** Milliseconds per day. */ +export const DAY_MS = 86_400_000; /** * Map a (correct, confidence) pair to an SM-2 quality score in [0, 5]. @@ -36,15 +37,55 @@ export function qualityOf(correct: boolean, confidence: Confidence): 0 | 1 | 2 | /** * Compute the next ScheduleEntry from the previous one + the latest quality. * - * Implementation lives on track T2's branch (`.trees/study-algorithms`). - * Until then this throws to make incomplete integration loud rather than silent. + * SM-2 algorithm: + * - quality < 3 → lapse: repetitions reset to 0, intervalDays = 1, lapses++. + * - quality ≥ 3 → recall: repetitions++, interval grows. + * - repetitions === 1 → intervalDays = 1 + * - repetitions === 2 → intervalDays = 6 + * - else → intervalDays = round(prevInterval * EF) + * - EF always updated with the standard formula, clamped to ≥ MIN_EASE. + * - nextDue = now + intervalDays * DAY_MS * * V1-PLAN §5.1. */ export function applySM2( - _previous: ScheduleEntry, - _quality: 0 | 1 | 2 | 3 | 4 | 5, - _now: number = Date.now(), + previous: ScheduleEntry, + quality: 0 | 1 | 2 | 3 | 4 | 5, + now: number = Date.now(), ): ScheduleEntry { - throw new Error('applySM2: implementation deferred to track T2 (study-algorithms)'); + let { intervalDays, easeFactor, repetitions, lapses, totalReviews } = previous; + + if (quality < 3) { + // Lapse: reset streak, schedule for review tomorrow. + repetitions = 0; + intervalDays = 1; + lapses += 1; + } else { + // Recall: advance the streak. + repetitions += 1; + if (repetitions === 1) { + intervalDays = 1; + } else if (repetitions === 2) { + intervalDays = 6; + } else { + intervalDays = Math.round(previous.intervalDays * easeFactor); + } + } + + // EF update is applied unconditionally (lapse and recall), per SM-2 paper. + easeFactor = Math.max( + MIN_EASE, + easeFactor + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02), + ); + + return { + questionId: previous.questionId, + nextDue: now + intervalDays * DAY_MS, + intervalDays, + easeFactor, + repetitions, + lapses, + lastReviewed: now, + totalReviews: totalReviews + 1, + }; } diff --git a/src/lib/quiz/selector.ts b/src/lib/quiz/selector.ts index fa8c644..851340f 100644 --- a/src/lib/quiz/selector.ts +++ b/src/lib/quiz/selector.ts @@ -1,32 +1,168 @@ /** - * Due-aware question selector — contract. + * Due-aware question selector — full implementation. * - * Track T2 owns the implementation. See V1-PLAN.md §5.2. + * Track T2 (`.trees/study-algorithms`) owns this file. + * + * See V1-PLAN.md §5.2. */ -import type { ScheduleEntry, SelectionRequest, StudyStore } from './study-types'; +import type { CategoryBudget, SelectionRequest, StudyStore } from './study-types'; +import type { Category } from './question-types'; /** Selection result is a list of question IDs in presentation order. */ export type SelectionResult = string[]; /** Helper for tests / non-async callers. */ export interface ScheduleLookup { - get(id: string): ScheduleEntry | null; + get(id: string): import('./study-types').ScheduleEntry | null; } /** * Pick `request.totalCount` question IDs from `request.pool` honouring: - * 1. Due items first (up to `reviewMixRatio` of total). - * 2. Fresh-fill: never-seen items for the remainder. - * 3. Stale-fallback: if no due and no fresh, oldest reviewed. + * 1. Due items first (up to `reviewMixRatio` of total, most overdue first). + * 2. Fresh-fill: never-seen items (no schedule entry) for the remainder, + * distributed across categories per `request.budgets`. + * 3. Stale-fallback: oldest `lastReviewed` items if still short. + * 4. Result is interleaved in round-robin category order to prevent runs. * - * Implementation deferred to track T2. + * V1-PLAN §5.2. */ export async function selectQuestionsV2( - _request: SelectionRequest, - _store: Pick, + request: SelectionRequest, + store: Pick, ): Promise { - throw new Error( - 'selectQuestionsV2: implementation deferred to track T2 (study-algorithms)', - ); + const { pool, budgets, totalCount, settings, now } = request; + + // Index pool items by id for fast lookup. + const poolById = new Map(pool.map((item) => [item.id, item])); + const poolIds = new Set(pool.map((item) => item.id)); + + // ── 1. Due items ────────────────────────────────────────────────────────── + const allDueIds = await store.getDueItems(now); + // Intersect with pool. + const dueInPool = allDueIds.filter((id) => poolIds.has(id)); + + // Fetch schedules for due items to sort by most overdue (lowest nextDue first). + const dueWithSchedule: Array<{ id: string; nextDue: number }> = []; + for (const id of dueInPool) { + const sched = await store.getSchedule(id); + dueWithSchedule.push({ id, nextDue: sched?.nextDue ?? 0 }); + } + // Most overdue = smallest nextDue relative to now. + dueWithSchedule.sort((a, b) => a.nextDue - b.nextDue); + + const dueBudget = Math.floor(totalCount * settings.reviewMixRatio); + const selectedDue = dueWithSchedule.slice(0, dueBudget).map((d) => d.id); + const selectedSet = new Set(selectedDue); + + // ── 2. Fresh-fill per category budget ───────────────────────────────────── + // Remaining slots after due items. + let remaining = totalCount - selectedDue.length; + + // Build per-category remaining budgets, subtracting already-selected due items. + const budgetMap = new Map(budgets.map((b) => [b.category, b.count])); + for (const id of selectedDue) { + const item = poolById.get(id); + if (item) { + const cat = item.category; + budgetMap.set(cat, (budgetMap.get(cat) ?? 0) - 1); + } + } + + // Collect fresh (never-seen) items per category. + const freshByCategory = new Map(); + for (const [cat] of budgetMap) { + freshByCategory.set(cat, []); + } + + for (const id of poolIds) { + if (selectedSet.has(id)) continue; + const item = poolById.get(id)!; + const sched = await store.getSchedule(id); + if (sched === null) { + // Never seen. + const catList = freshByCategory.get(item.category); + if (catList !== undefined) catList.push(id); + } + } + + const selectedFresh: string[] = []; + for (const [cat, catBudget] of budgetMap) { + if (catBudget <= 0 || remaining <= 0) continue; + const fresh = freshByCategory.get(cat) ?? []; + const take = Math.min(catBudget, remaining, fresh.length); + for (let i = 0; i < take; i++) { + selectedFresh.push(fresh[i]); + selectedSet.add(fresh[i]); + } + remaining -= take; + } + + // ── 3. Stale-fallback ───────────────────────────────────────────────────── + // Stale items = reviewed but NOT currently due and not yet selected. + // Explicitly exclude due-in-pool items that weren't chosen within the due budget. + const dueInPoolSet = new Set(dueInPool); + const selectedStale: string[] = []; + if (remaining > 0) { + const staleItems: Array<{ id: string; lastReviewed: number }> = []; + for (const id of poolIds) { + if (selectedSet.has(id)) continue; + if (dueInPoolSet.has(id)) continue; // skip un-budgeted due items + const sched = await store.getSchedule(id); + if (sched !== null) { + staleItems.push({ id, lastReviewed: sched.lastReviewed }); + } + } + // Oldest first (smallest lastReviewed). + staleItems.sort((a, b) => a.lastReviewed - b.lastReviewed); + for (const { id } of staleItems.slice(0, remaining)) { + selectedStale.push(id); + selectedSet.add(id); + } + } + + // ── 4. Round-robin interleaving across categories ───────────────────────── + const all = [...selectedDue, ...selectedFresh, ...selectedStale]; + return roundRobinInterleave(all, poolById); +} + +/** + * Interleave a flat list of IDs by their category in true round-robin order: + * one item per category per cycle, so consecutive items are from different + * categories (when multiple categories are present). + */ +function roundRobinInterleave( + ids: string[], + poolById: Map, +): string[] { + // Group by category preserving encounter order within each category. + const byCategory = new Map(); + for (const id of ids) { + const item = poolById.get(id); + if (!item) continue; + const cat = item.category; + if (!byCategory.has(cat)) byCategory.set(cat, []); + byCategory.get(cat)!.push(id); + } + + const queues = Array.from(byCategory.values()); + const result: string[] = []; + let cycleStart = 0; + + // Each outer iteration picks ONE item from the next non-empty queue. + while (result.length < ids.length) { + let pickedAny = false; + for (let q = 0; q < queues.length; q++) { + const queue = queues[(cycleStart + q) % queues.length]; + if (queue.length > 0) { + result.push(queue.shift()!); + pickedAny = true; + // Advance cycleStart so the next pick starts from the following queue. + cycleStart = (cycleStart + q + 1) % queues.length; + break; + } + } + if (!pickedAny) break; + } + return result; } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..44756b6 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,31 @@ +/// +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + include: [ + 'src/lib/quiz/scheduler.ts', + 'src/lib/quiz/selector.ts', + 'src/lib/quiz/interleaver.ts', + ], + thresholds: { + lines: 90, + functions: 90, + branches: 90, + statements: 90, + }, + }, + }, + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + '@lib': resolve(__dirname, 'src/lib'), + '@components': resolve(__dirname, 'src/components'), + }, + }, +}); From f6d0d34ddd09311bc8366e4a446ccc5858b1c8d5 Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:21:04 +0700 Subject: [PATCH 05/16] =?UTF-8?q?test(study):=20scheduler/selector/interle?= =?UTF-8?q?aver=20test=20suites,=2071=20tests=20=E2=89=A590%=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scheduler.test.ts: qualityOf mapping, lapse invariants (reset rep/interval, lapses++), recall invariants (rep/interval progression), EF clamping to ≥1.3, hand-rolled property test asserting non-decreasing intervals on any correct-answer streak, concrete q=5 example verifying 1→6→~15→~38 progression per V1-PLAN §5.1. selector.test.ts: in-memory FakeStore; covers empty store, due-first ordering, reviewMixRatio ceiling, per-category budgets, stale-fallback, round-robin interleaving, edge cases (orphan due IDs, null schedule in stale path, categories outside budgets). interleaver.test.ts: spacing enforcement, determinism, graceful violation fallback, impossible constraints, 20-item multi-pair scenario. Coverage: 90.62% branches, 100% functions/lines across all three modules. --- src/lib/quiz/interleaver.test.ts | 186 +++++++++++ src/lib/quiz/scheduler.test.ts | 271 ++++++++++++++++ src/lib/quiz/selector.test.ts | 514 +++++++++++++++++++++++++++++++ 3 files changed, 971 insertions(+) create mode 100644 src/lib/quiz/interleaver.test.ts create mode 100644 src/lib/quiz/scheduler.test.ts create mode 100644 src/lib/quiz/selector.test.ts diff --git a/src/lib/quiz/interleaver.test.ts b/src/lib/quiz/interleaver.test.ts new file mode 100644 index 0000000..4591733 --- /dev/null +++ b/src/lib/quiz/interleaver.test.ts @@ -0,0 +1,186 @@ +/** + * Tests for interleaver.ts — interleaveNearMisses. + * + * Verifies: + * - Confusion pairs are spaced ≥ minSpacing apart + * - Deterministic: same input always produces same output + * - Graceful fallback when constraint cannot be satisfied + * - No-op when no confusion pairs + * - Input IDs all present in output (no loss / no duplication) + */ + +import { describe, it, expect } from 'vitest'; +import { interleaveNearMisses } from './interleaver'; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function pairs(map: Record): ReadonlyMap { + return new Map(Object.entries(map)); +} + +/** Check that every confusion pair in the map is ≥ minSpacing apart in result. */ +function assertSpaced(result: string[], confusionPairs: ReadonlyMap, minSpacing: number) { + for (const [id, partners] of confusionPairs) { + const idxA = result.indexOf(id); + if (idxA === -1) continue; + for (const partner of partners) { + const idxB = result.indexOf(partner); + if (idxB === -1) continue; + const distance = Math.abs(idxA - idxB); + expect(distance).toBeGreaterThanOrEqual(minSpacing); + } + } +} + +// ── basic correctness ───────────────────────────────────────────────────────── + +describe('interleaveNearMisses — basic correctness', () => { + it('preserves all input IDs (no additions, no deletions)', () => { + const ids = ['a', 'b', 'c', 'd', 'e']; + const result = interleaveNearMisses(ids, pairs({ a: ['b'] }), 3); + expect(result.sort()).toEqual([...ids].sort()); + }); + + it('no duplicates in output', () => { + const ids = ['a', 'b', 'c', 'd']; + const result = interleaveNearMisses(ids, pairs({ a: ['b'] }), 2); + expect(new Set(result).size).toBe(result.length); + }); + + it('returns empty array for empty input', () => { + const result = interleaveNearMisses([], pairs({}), 3); + expect(result).toEqual([]); + }); + + it('single-item input is unchanged', () => { + const result = interleaveNearMisses(['only'], pairs({}), 3); + expect(result).toEqual(['only']); + }); +}); + +// ── no confusion pairs — input preserved in order ──────────────────────────── + +describe('interleaveNearMisses — no confusion pairs', () => { + it('returns input order when no pairs defined', () => { + const ids = ['a', 'b', 'c', 'd', 'e']; + const result = interleaveNearMisses(ids, new Map(), 3); + expect(result).toEqual(ids); + }); + + it('returns input order when pairs map is empty', () => { + const ids = ['x', 'y', 'z']; + const result = interleaveNearMisses(ids, pairs({}), 3); + expect(result).toEqual(ids); + }); +}); + +// ── spacing enforcement ─────────────────────────────────────────────────────── + +describe('interleaveNearMisses — spacing enforcement', () => { + it('spaces confusion pair ≥ 3 apart (default)', () => { + // 'a' and 'b' start adjacent; should be spaced out. + const ids = ['a', 'b', 'c', 'd', 'e', 'f']; + const cp = pairs({ a: ['b'], b: ['a'] }); + const result = interleaveNearMisses(ids, cp, 3); + assertSpaced(result, cp, 3); + }); + + it('spaces confusion pair ≥ 2 apart when minSpacing=2', () => { + const ids = ['a', 'b', 'c', 'd']; + const cp = pairs({ a: ['b'], b: ['a'] }); + const result = interleaveNearMisses(ids, cp, 2); + assertSpaced(result, cp, 2); + }); + + it('spaces multiple disjoint pairs', () => { + const ids = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']; + const cp = pairs({ a: ['b'], b: ['a'], c: ['d'], d: ['c'] }); + const result = interleaveNearMisses(ids, cp, 3); + assertSpaced(result, cp, 3); + }); + + it('spaces overlapping confusion sets (a confused with b and c)', () => { + const ids = ['a', 'b', 'c', 'd', 'e', 'f']; + const cp = pairs({ a: ['b', 'c'], b: ['a'], c: ['a'] }); + const result = interleaveNearMisses(ids, cp, 2); + assertSpaced(result, cp, 2); + }); +}); + +// ── determinism ─────────────────────────────────────────────────────────────── + +describe('interleaveNearMisses — determinism', () => { + it('same inputs always produce same output', () => { + const ids = ['a', 'b', 'c', 'd', 'e', 'f']; + const cp = pairs({ a: ['b'], b: ['a'] }); + const r1 = interleaveNearMisses(ids, cp, 3); + const r2 = interleaveNearMisses(ids, cp, 3); + const r3 = interleaveNearMisses(ids, cp, 3); + expect(r1).toEqual(r2); + expect(r1).toEqual(r3); + }); + + it('different input orders produce deterministic (possibly different) outputs', () => { + const cp = pairs({ a: ['b'], b: ['a'] }); + const r1 = interleaveNearMisses(['a', 'b', 'c', 'd'], cp, 3); + const r2 = interleaveNearMisses(['b', 'a', 'c', 'd'], cp, 3); + // Both must be self-consistent — calling twice each gives the same result. + expect(r1).toEqual(interleaveNearMisses(['a', 'b', 'c', 'd'], cp, 3)); + expect(r2).toEqual(interleaveNearMisses(['b', 'a', 'c', 'd'], cp, 3)); + }); +}); + +// ── fallback when constraint cannot be satisfied ────────────────────────────── + +describe('interleaveNearMisses — impossible constraint fallback', () => { + it('returns a valid permutation even when spacing is impossible', () => { + // Only 2 items; minSpacing=5 is impossible. + const ids = ['a', 'b']; + const cp = pairs({ a: ['b'], b: ['a'] }); + const result = interleaveNearMisses(ids, cp, 5); + // Must still contain both items, no duplicates. + expect(result.sort()).toEqual(['a', 'b']); + }); + + it('handles fully entangled pairs (all confused with all)', () => { + const ids = ['a', 'b', 'c']; + const cp = pairs({ a: ['b', 'c'], b: ['a', 'c'], c: ['a', 'b'] }); + // minSpacing=3 is impossible for 3 items. + const result = interleaveNearMisses(ids, cp, 3); + expect(result.sort()).toEqual(['a', 'b', 'c']); + }); + + it('accepts violation gracefully and terminates', () => { + // Ensure no infinite loop. + const ids = Array.from({ length: 10 }, (_, i) => `q${i}`); + const allPairs: Record = {}; + for (const id of ids) allPairs[id] = ids.filter((x) => x !== id); + const cp = pairs(allPairs); + + // Should complete (not hang). + const result = interleaveNearMisses(ids, cp, 9); + expect(result).toHaveLength(10); + }); +}); + +// ── large input ─────────────────────────────────────────────────────────────── + +describe('interleaveNearMisses — larger inputs', () => { + it('correctly spaces 20 items with 5 confusion pairs', () => { + const ids = Array.from({ length: 20 }, (_, i) => `q${i}`); + const cp = pairs({ + q0: ['q1'], + q1: ['q0'], + q5: ['q6'], + q6: ['q5'], + q10: ['q11'], + q11: ['q10'], + q15: ['q16'], + q16: ['q15'], + }); + const result = interleaveNearMisses(ids, cp, 3); + expect(result).toHaveLength(20); + expect(new Set(result).size).toBe(20); + assertSpaced(result, cp, 3); + }); +}); diff --git a/src/lib/quiz/scheduler.test.ts b/src/lib/quiz/scheduler.test.ts new file mode 100644 index 0000000..6dc62e8 --- /dev/null +++ b/src/lib/quiz/scheduler.test.ts @@ -0,0 +1,271 @@ +/** + * Tests for scheduler.ts — SM-2 implementation. + * + * Verifies: + * - qualityOf mapping + * - Lapse behaviour (q < 3) + * - Recall behaviour (q ≥ 3) + * - EF clamped to ≥ 1.3 + * - Interval growth on streak (hand-rolled property test) + * - Specific values from V1-PLAN §5.1 examples (q=5 streak) + */ + +import { describe, it, expect } from 'vitest'; +import { + applySM2, + qualityOf, + DEFAULT_EASE, + MIN_EASE, + DAY_MS, +} from './scheduler'; +import { newScheduleEntry } from './study-types'; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +const NOW = 1_700_000_000_000; // arbitrary fixed timestamp + +function freshEntry(id = 'q1') { + return newScheduleEntry(id, NOW); +} + +/** Apply n consecutive quality=5 answers and return all entries (including fresh). */ +function runStreak(quality: 0 | 1 | 2 | 3 | 4 | 5, n: number) { + const entries = [freshEntry()]; + for (let i = 0; i < n; i++) { + entries.push(applySM2(entries[entries.length - 1], quality, NOW)); + } + return entries; +} + +// ── qualityOf ───────────────────────────────────────────────────────────────── + +describe('qualityOf', () => { + it('correct+certain → 5', () => expect(qualityOf(true, 'certain')).toBe(5)); + it('correct+fairly-sure → 4', () => expect(qualityOf(true, 'fairly-sure')).toBe(4)); + it('correct+unsure → 3', () => expect(qualityOf(true, 'unsure')).toBe(3)); + it('incorrect+unsure → 2', () => expect(qualityOf(false, 'unsure')).toBe(2)); + it('incorrect+fairly-sure → 1', () => expect(qualityOf(false, 'fairly-sure')).toBe(1)); + it('incorrect+certain → 0', () => expect(qualityOf(false, 'certain')).toBe(0)); +}); + +// ── lapse (q < 3) ───────────────────────────────────────────────────────────── + +describe('applySM2 — lapse (quality < 3)', () => { + it('resets repetitions to 0', () => { + // First build up a streak so repetitions > 0. + const after2 = runStreak(5, 2); + const entry = after2[after2.length - 1]; + expect(entry.repetitions).toBe(2); + + const lapsed = applySM2(entry, 0, NOW); + expect(lapsed.repetitions).toBe(0); + }); + + it('sets intervalDays to 1 on lapse', () => { + const streak = runStreak(5, 5); + const entry = streak[streak.length - 1]; + expect(entry.intervalDays).toBeGreaterThan(6); + + const lapsed = applySM2(entry, 1, NOW); + expect(lapsed.intervalDays).toBe(1); + }); + + it('increments lapses counter', () => { + const entry = freshEntry(); + expect(entry.lapses).toBe(0); + const lapsed = applySM2(entry, 0, NOW); + expect(lapsed.lapses).toBe(1); + const lapsed2 = applySM2(lapsed, 2, NOW); + expect(lapsed2.lapses).toBe(2); + }); + + it('still updates totalReviews on lapse', () => { + const entry = freshEntry(); + const result = applySM2(entry, 0, NOW); + expect(result.totalReviews).toBe(1); + }); + + it('updates lastReviewed on lapse', () => { + const entry = freshEntry(); + const result = applySM2(entry, 0, NOW + 1000); + expect(result.lastReviewed).toBe(NOW + 1000); + }); + + it('sets nextDue to now + 1 day on lapse', () => { + const entry = freshEntry(); + const t = NOW + 5000; + const result = applySM2(entry, 2, t); + expect(result.nextDue).toBe(t + DAY_MS); + }); + + it.each([0, 1, 2] as const)('q=%i is a lapse', (q) => { + const result = applySM2(freshEntry(), q, NOW); + expect(result.repetitions).toBe(0); + expect(result.lapses).toBe(1); + }); +}); + +// ── recall (q ≥ 3) ─────────────────────────────────────────────────────────── + +describe('applySM2 — recall (quality ≥ 3)', () => { + it('first recall: repetitions=1, intervalDays=1', () => { + const entry = freshEntry(); + const result = applySM2(entry, 5, NOW); + expect(result.repetitions).toBe(1); + expect(result.intervalDays).toBe(1); + }); + + it('second recall: repetitions=2, intervalDays=6', () => { + const after1 = applySM2(freshEntry(), 5, NOW); + const after2 = applySM2(after1, 5, NOW); + expect(after2.repetitions).toBe(2); + expect(after2.intervalDays).toBe(6); + }); + + it('third recall: intervalDays = round(6 * EF)', () => { + const after1 = applySM2(freshEntry(), 5, NOW); + const after2 = applySM2(after1, 5, NOW); + const ef = after2.easeFactor; + const after3 = applySM2(after2, 5, NOW); + expect(after3.intervalDays).toBe(Math.round(6 * ef)); + }); + + it('does not change lapses on recall', () => { + const result = applySM2(freshEntry(), 3, NOW); + expect(result.lapses).toBe(0); + }); + + it.each([3, 4, 5] as const)('q=%i increments repetitions', (q) => { + const result = applySM2(freshEntry(), q, NOW); + expect(result.repetitions).toBe(1); + }); +}); + +// ── EF clamping ─────────────────────────────────────────────────────────────── + +describe('applySM2 — ease factor', () => { + it('initialises at DEFAULT_EASE (2.5)', () => { + expect(freshEntry().easeFactor).toBe(DEFAULT_EASE); + }); + + it('EF increases on q=5', () => { + const result = applySM2(freshEntry(), 5, NOW); + expect(result.easeFactor).toBeGreaterThan(DEFAULT_EASE); + }); + + it('EF decreases on q=3', () => { + const result = applySM2(freshEntry(), 3, NOW); + expect(result.easeFactor).toBeLessThan(DEFAULT_EASE); + }); + + it('EF is clamped to MIN_EASE (1.3)', () => { + // Drive EF down with repeated low-quality answers. + let entry = freshEntry(); + for (let i = 0; i < 30; i++) { + entry = applySM2(entry, 3, NOW); + } + expect(entry.easeFactor).toBeGreaterThanOrEqual(MIN_EASE); + }); + + it('EF never goes below MIN_EASE even on q=0', () => { + // Force entry with very low EF by cheating via object spread. + const lowEF = { ...freshEntry(), easeFactor: 1.3 }; + const result = applySM2(lowEF, 0, NOW); + expect(result.easeFactor).toBeGreaterThanOrEqual(MIN_EASE); + }); + + it('EF grows linearly with q=5 (no upper cap in SM-2) — stays finite', () => { + let entry = freshEntry(); + for (let i = 0; i < 100; i++) { + entry = applySM2(entry, 5, NOW); + } + // SM-2 has no upper cap on EF; with q=5 it grows by +0.1 per review. + // After 100 perfect reviews: EF ≈ 2.5 + 100*0.1 = 12.5. + expect(Number.isFinite(entry.easeFactor)).toBe(true); + expect(entry.easeFactor).toBeGreaterThan(2.5); + // Verify the growth is approximately +0.1/review. + expect(entry.easeFactor).toBeCloseTo(2.5 + 100 * 0.1, 0); + }); +}); + +// ── interval growth invariant (property test) ──────────────────────────────── + +describe('applySM2 — interval growth property', () => { + /** + * For any sequence of correct answers (q ∈ {3,4,5}), after the first two + * items the sequence of intervalDays must be non-decreasing. + */ + function assertNonDecreasingIntervals(qualities: (3 | 4 | 5)[]) { + const entries = [freshEntry()]; + for (const q of qualities) { + entries.push(applySM2(entries[entries.length - 1], q, NOW)); + } + const intervals = entries.slice(2).map((e) => e.intervalDays); + for (let i = 1; i < intervals.length; i++) { + expect(intervals[i]).toBeGreaterThanOrEqual(intervals[i - 1]); + } + } + + it('all-5 streak: intervals non-decreasing after first two', () => { + assertNonDecreasingIntervals([5, 5, 5, 5, 5, 5, 5, 5]); + }); + + it('all-4 streak: intervals non-decreasing after first two', () => { + assertNonDecreasingIntervals([4, 4, 4, 4, 4, 4, 4, 4]); + }); + + it('all-3 streak: intervals non-decreasing after first two', () => { + assertNonDecreasingIntervals([3, 3, 3, 3, 3, 3, 3, 3]); + }); + + it('mixed 3/4/5 streak: intervals non-decreasing after first two', () => { + assertNonDecreasingIntervals([3, 5, 4, 3, 5, 5, 4, 3, 5]); + }); + + /** + * Hand-rolled property test: generate 20 random quality sequences of length + * 10 (all ≥ 3) and assert the invariant holds for each. + */ + it('non-decreasing intervals: hand-rolled property over 20 sequences', () => { + const possible: (3 | 4 | 5)[] = [3, 4, 5]; + // Deterministic via LCG seeded from NOW to keep test reproducible. + let seed = 42; + function nextInt(n: number) { + seed = (seed * 1664525 + 1013904223) & 0x7fffffff; + return seed % n; + } + + for (let trial = 0; trial < 20; trial++) { + const seq: (3 | 4 | 5)[] = Array.from({ length: 10 }, () => possible[nextInt(3)]); + assertNonDecreasingIntervals(seq); + } + }); +}); + +// ── concrete examples from V1-PLAN §5.1 ────────────────────────────────────── + +describe('applySM2 — concrete examples (q=5 streak)', () => { + it('intervals follow 1 → 6 → ~15 → ~38 pattern with q=5', () => { + const entries = runStreak(5, 5); + const intervals = entries.slice(1).map((e) => e.intervalDays); + // rep1 → 1, rep2 → 6, rep3 → round(6 * EF_after_2), … + expect(intervals[0]).toBe(1); + expect(intervals[1]).toBe(6); + // After two perfect reps, EF ≈ 2.6 → interval[2] ≈ 16 + expect(intervals[2]).toBeGreaterThan(12); + expect(intervals[2]).toBeLessThan(20); + // interval[3] should be larger than interval[2] + expect(intervals[3]).toBeGreaterThan(intervals[2]); + expect(intervals[4]).toBeGreaterThan(intervals[3]); + }); +}); + +// ── preserves questionId ─────────────────────────────────────────────────────── + +describe('applySM2 — identity fields', () => { + it('preserves questionId', () => { + const entry = freshEntry('my-question'); + const result = applySM2(entry, 5, NOW); + expect(result.questionId).toBe('my-question'); + }); +}); diff --git a/src/lib/quiz/selector.test.ts b/src/lib/quiz/selector.test.ts new file mode 100644 index 0000000..49622ca --- /dev/null +++ b/src/lib/quiz/selector.test.ts @@ -0,0 +1,514 @@ +/** + * Tests for selector.ts — selectQuestionsV2. + * + * Uses an in-memory fake StudyStore so tests are pure and synchronous-ish. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { selectQuestionsV2 } from './selector'; +import type { + ScheduleEntry, + SelectionRequest, + StudyStore, +} from './study-types'; +import { DEFAULT_STUDY_SETTINGS } from './study-types'; +import type { Category } from './question-types'; + +// ── Fake store ──────────────────────────────────────────────────────────────── + +class FakeStore implements Pick { + private schedules = new Map(); + /** Extra IDs returned by getDueItems even if they have no schedule entry. */ + private extraDueIds: string[] = []; + + /** Register a schedule for a question ID. */ + setSchedule(id: string, entry: Partial) { + this.schedules.set(id, { + questionId: id, + nextDue: 0, + intervalDays: 1, + easeFactor: 2.5, + repetitions: 1, + lapses: 0, + lastReviewed: 1_000_000, + totalReviews: 1, + ...entry, + }); + } + + /** Force an ID to appear in getDueItems without a backing schedule. */ + addExtraDueId(id: string) { + this.extraDueIds.push(id); + } + + async getSchedule(questionId: string): Promise { + return this.schedules.get(questionId) ?? null; + } + + async getDueItems(now: number): Promise { + const due: string[] = [...this.extraDueIds]; + for (const [id, entry] of this.schedules) { + if (entry.nextDue <= now) due.push(id); + } + return due; + } +} + +// ── Pool helpers ─────────────────────────────────────────────────────────────── + +const CATS: Category[] = ['fundamentals', 'species', 'equipment']; + +function makePool(ids: string[], cat: Category = 'fundamentals') { + return ids.map((id) => ({ id, category: cat })); +} + +function makeRequest( + pool: SelectionRequest['pool'], + totalCount: number, + now = 2_000_000_000, + reviewMixRatio = 0.5, +): SelectionRequest { + const catCounts = new Map(); + for (const item of pool) { + catCounts.set(item.category, (catCounts.get(item.category) ?? 0) + 1); + } + const budgets = Array.from(catCounts.entries()).map(([category, count]) => ({ + category, + count: Math.min(count, totalCount), + })); + return { + pool, + budgets, + totalCount, + settings: { reviewMixRatio }, + now, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('selectQuestionsV2 — empty store (all fresh)', () => { + it('returns totalCount IDs when all items are fresh', async () => { + const store = new FakeStore(); + const pool = makePool(['q1', 'q2', 'q3', 'q4', 'q5']); + const req = makeRequest(pool, 3); + const result = await selectQuestionsV2(req, store); + expect(result).toHaveLength(3); + }); + + it('all returned IDs are from the pool', async () => { + const store = new FakeStore(); + const pool = makePool(['a', 'b', 'c', 'd']); + const req = makeRequest(pool, 4); + const result = await selectQuestionsV2(req, store); + const poolIds = new Set(pool.map((p) => p.id)); + for (const id of result) { + expect(poolIds.has(id)).toBe(true); + } + }); + + it('no duplicates in result', async () => { + const store = new FakeStore(); + const pool = makePool(['q1', 'q2', 'q3', 'q4', 'q5', 'q6']); + const req = makeRequest(pool, 5); + const result = await selectQuestionsV2(req, store); + expect(new Set(result).size).toBe(result.length); + }); +}); + +describe('selectQuestionsV2 — due items respected', () => { + it('includes due items when reviewMixRatio allows', async () => { + const store = new FakeStore(); + const now = 2_000_000_000; + // Make q1 and q2 due. + store.setSchedule('q1', { nextDue: now - 1000 }); + store.setSchedule('q2', { nextDue: now - 500 }); + // q3, q4, q5 are fresh (no schedule). + + const pool = makePool(['q1', 'q2', 'q3', 'q4', 'q5']); + const req = makeRequest(pool, 4, now, 0.5); + const result = await selectQuestionsV2(req, store); + + expect(result).toHaveLength(4); + // reviewMixRatio=0.5, totalCount=4 → dueBudget=2 + // Both due items should be included. + expect(result).toContain('q1'); + expect(result).toContain('q2'); + }); + + it('respects reviewMixRatio ceiling on due items', async () => { + const store = new FakeStore(); + const now = 3_000_000_000; + // q1-q5 are due. q6-q10 have no schedule (fresh). + for (const id of ['q1', 'q2', 'q3', 'q4', 'q5']) { + store.setSchedule(id, { nextDue: now - 1 }); + } + + const pool = makePool(['q1', 'q2', 'q3', 'q4', 'q5', 'q6', 'q7', 'q8', 'q9', 'q10']); + const req = makeRequest(pool, 10, now, 0.3); + const result = await selectQuestionsV2(req, store); + + // dueBudget = floor(10 * 0.3) = 3. Due-but-unbudgeted items are excluded + // from stale-fallback, so only 3 due + 5 fresh = 8 total available. + // Count how many of q1-q5 appear in result. + const dueInResult = result.filter((id) => ['q1', 'q2', 'q3', 'q4', 'q5'].includes(id)); + expect(dueInResult.length).toBeLessThanOrEqual(3); + // All fresh (q6-q10) should be included. + for (const id of ['q6', 'q7', 'q8', 'q9', 'q10']) { + expect(result).toContain(id); + } + }); + + it('most overdue items selected first when budget limited', async () => { + const store = new FakeStore(); + const now = 5_000_000_000; + // q1 is most overdue, q3 is least overdue (all three are due). + store.setSchedule('q1', { nextDue: now - 1_000_000 }); + store.setSchedule('q2', { nextDue: now - 500_000 }); + store.setSchedule('q3', { nextDue: now - 100 }); + // q4, q5 fresh (no schedule). + + const pool = makePool(['q1', 'q2', 'q3', 'q4', 'q5']); + // reviewMixRatio=0.4 → dueBudget=floor(5*0.4)=2 → only 2 due items fit. + const req = makeRequest(pool, 5, now, 0.4); + const result = await selectQuestionsV2(req, store); + + // q1 and q2 should be the two chosen due items (most overdue first). + expect(result).toContain('q1'); + expect(result).toContain('q2'); + // q3 is due but not budgeted; it is excluded from stale-fallback too. + expect(result).not.toContain('q3'); + // q4, q5 fill fresh slots. + expect(result).toContain('q4'); + expect(result).toContain('q5'); + // Total = 2 due + 2 fresh = 4 (q3 excluded from both paths). + expect(result).toHaveLength(4); + }); +}); + +describe('selectQuestionsV2 — half due, half fresh (reviewMixRatio=0.5)', () => { + it('picks approx half from due and half from fresh', async () => { + const store = new FakeStore(); + const now = 4_000_000_000; + // q1, q2 due; q3, q4 fresh. + store.setSchedule('q1', { nextDue: now - 1000 }); + store.setSchedule('q2', { nextDue: now - 2000 }); + + const pool = makePool(['q1', 'q2', 'q3', 'q4']); + const req = makeRequest(pool, 4, now, 0.5); + const result = await selectQuestionsV2(req, store); + + expect(result).toHaveLength(4); + // dueBudget = floor(4 * 0.5) = 2 → q1, q2 from due. + expect(result).toContain('q1'); + expect(result).toContain('q2'); + // q3, q4 should fill the rest (fresh). + expect(result).toContain('q3'); + expect(result).toContain('q4'); + }); +}); + +describe('selectQuestionsV2 — per-category budgets honoured', () => { + it('does not exceed per-category budgets', async () => { + const store = new FakeStore(); + const now = 6_000_000_000; + + // Build a multi-category pool. + const pool: SelectionRequest['pool'] = [ + { id: 'f1', category: 'fundamentals' }, + { id: 'f2', category: 'fundamentals' }, + { id: 'f3', category: 'fundamentals' }, + { id: 's1', category: 'species' }, + { id: 's2', category: 'species' }, + { id: 'e1', category: 'equipment' }, + ]; + + const req: SelectionRequest = { + pool, + budgets: [ + { category: 'fundamentals', count: 2 }, + { category: 'species', count: 1 }, + { category: 'equipment', count: 1 }, + ], + totalCount: 4, + settings: { reviewMixRatio: 0 }, // force all fresh + now, + }; + + const result = await selectQuestionsV2(req, store); + expect(result).toHaveLength(4); + + const byCategory = new Map(); + for (const id of result) { + const item = pool.find((p) => p.id === id)!; + byCategory.set(item.category, (byCategory.get(item.category) ?? 0) + 1); + } + expect(byCategory.get('fundamentals') ?? 0).toBeLessThanOrEqual(2); + expect(byCategory.get('species') ?? 0).toBeLessThanOrEqual(1); + expect(byCategory.get('equipment') ?? 0).toBeLessThanOrEqual(1); + }); +}); + +describe('selectQuestionsV2 — stale fallback', () => { + it('falls back to stale items when no due and no fresh', async () => { + const store = new FakeStore(); + const now = 7_000_000_000; + // All items have been reviewed but are not yet due. + store.setSchedule('q1', { nextDue: now + 1_000_000, lastReviewed: now - 3000 }); + store.setSchedule('q2', { nextDue: now + 2_000_000, lastReviewed: now - 2000 }); + store.setSchedule('q3', { nextDue: now + 3_000_000, lastReviewed: now - 1000 }); + + const pool = makePool(['q1', 'q2', 'q3']); + const req = makeRequest(pool, 2, now, 0.5); + const result = await selectQuestionsV2(req, store); + + expect(result).toHaveLength(2); + // No due, no fresh — must come from stale. + // Oldest lastReviewed first: q1 then q2. + expect(result).toContain('q1'); + expect(result).toContain('q2'); + }); + + it('stale items are ordered by oldest lastReviewed first', async () => { + const store = new FakeStore(); + const now = 8_000_000_000; + + store.setSchedule('recent', { nextDue: now + 1_000_000, lastReviewed: now - 100 }); + store.setSchedule('oldest', { nextDue: now + 1_000_000, lastReviewed: now - 100_000 }); + store.setSchedule('middle', { nextDue: now + 1_000_000, lastReviewed: now - 10_000 }); + + const pool = makePool(['recent', 'oldest', 'middle']); + const req = makeRequest(pool, 2, now, 0); + const result = await selectQuestionsV2(req, store); + + expect(result).toContain('oldest'); + expect(result).toContain('middle'); + expect(result).not.toContain('recent'); + }); +}); + +describe('selectQuestionsV2 — result ordering (round-robin)', () => { + it('interleaves categories so consecutive items differ', async () => { + const store = new FakeStore(); + const now = 9_000_000_000; + + // 3 fundamentals, 3 species — all fresh. + const pool: SelectionRequest['pool'] = [ + { id: 'f1', category: 'fundamentals' }, + { id: 'f2', category: 'fundamentals' }, + { id: 'f3', category: 'fundamentals' }, + { id: 's1', category: 'species' }, + { id: 's2', category: 'species' }, + { id: 's3', category: 'species' }, + ]; + + const req: SelectionRequest = { + pool, + budgets: [ + { category: 'fundamentals', count: 3 }, + { category: 'species', count: 3 }, + ], + totalCount: 6, + settings: { reviewMixRatio: 0 }, + now, + }; + + const result = await selectQuestionsV2(req, store); + expect(result).toHaveLength(6); + + // Verify no two adjacent items are from the same category. + const getCategory = (id: string) => pool.find((p) => p.id === id)!.category; + for (let i = 1; i < result.length; i++) { + expect(getCategory(result[i])).not.toBe(getCategory(result[i - 1])); + } + }); +}); + +describe('selectQuestionsV2 — pool smaller than totalCount', () => { + it('returns at most pool.length items', async () => { + const store = new FakeStore(); + const pool = makePool(['q1', 'q2']); + const req = makeRequest(pool, 10); + const result = await selectQuestionsV2(req, store); + expect(result.length).toBeLessThanOrEqual(2); + }); +}); + +describe('selectQuestionsV2 — empty pool', () => { + it('returns empty array for empty pool', async () => { + const store = new FakeStore(); + const req = makeRequest([], 5); + const result = await selectQuestionsV2(req, store); + expect(result).toHaveLength(0); + }); +}); + +describe('selectQuestionsV2 — budget exhausted (catBudget <= 0)', () => { + it('skips category when its budget is fully consumed by due items', async () => { + const store = new FakeStore(); + const now = 10_000_000_000; + + // fundamentals budget=1; the one due fundamentals item fills it. + store.setSchedule('f1', { nextDue: now - 1000 }); + + const pool: SelectionRequest['pool'] = [ + { id: 'f1', category: 'fundamentals' }, + { id: 'f2', category: 'fundamentals' }, // fresh but over budget + { id: 's1', category: 'species' }, // fresh + ]; + + const req: SelectionRequest = { + pool, + budgets: [ + { category: 'fundamentals', count: 1 }, + { category: 'species', count: 1 }, + ], + totalCount: 2, + settings: { reviewMixRatio: 0.5 }, // dueBudget = floor(2*0.5) = 1 + now, + }; + + const result = await selectQuestionsV2(req, store); + // f1 (due, fills fundamentals budget), s1 (fresh species). + expect(result).toContain('f1'); + expect(result).toContain('s1'); + expect(result).not.toContain('f2'); + }); +}); + +describe('selectQuestionsV2 — stale fallback when no fresh but pool is reviewed', () => { + it('triggers stale path when all reviewed items are not due', async () => { + const store = new FakeStore(); + const now = 11_000_000_000; + + // Both items reviewed but not yet due. + store.setSchedule('q1', { nextDue: now + 86_400_000, lastReviewed: now - 5000 }); + store.setSchedule('q2', { nextDue: now + 86_400_000 * 2, lastReviewed: now - 1000 }); + + const pool = makePool(['q1', 'q2']); + const req = makeRequest(pool, 2, now, 0.5); + const result = await selectQuestionsV2(req, store); + + // Both come from stale fallback. + expect(result).toHaveLength(2); + expect(result).toContain('q1'); + expect(result).toContain('q2'); + }); +}); + +describe('selectQuestionsV2 — single-category pool (tests pickedAny=false path)', () => { + it('handles single-category pool correctly with round-robin', async () => { + const store = new FakeStore(); + const pool = makePool(['a', 'b', 'c'], 'equipment'); + const req = makeRequest(pool, 3, 1_000_000, 0); + const result = await selectQuestionsV2(req, store); + + expect(result).toHaveLength(3); + // All from same category — interleaving just preserves order. + const cats = result.map(() => 'equipment'); + expect(cats).toHaveLength(3); + }); +}); + +describe('selectQuestionsV2 — due item with missing schedule (null schedule path)', () => { + it('uses nextDue=0 fallback when a due item has no schedule entry', async () => { + const store = new FakeStore(); + const now = 15_000_000_000; + + // q_orphan is returned by getDueItems but has no schedule. + // q1 has a schedule with a specific nextDue. + store.addExtraDueId('q_orphan'); + store.setSchedule('q1', { nextDue: now - 5000 }); + + const pool = makePool(['q_orphan', 'q1', 'q2']); + const req = makeRequest(pool, 2, now, 1.0); // dueBudget = 2 + const result = await selectQuestionsV2(req, store); + + // Both due items should be selected. + expect(result).toContain('q_orphan'); + expect(result).toContain('q1'); + }); +}); + +describe('selectQuestionsV2 — pool items outside budget categories', () => { + it('ignores pool items whose category has no budget entry', async () => { + const store = new FakeStore(); + const now = 14_000_000_000; + + // Pool has a 'phenomena' item but budgets only cover 'fundamentals'. + const pool: SelectionRequest['pool'] = [ + { id: 'f1', category: 'fundamentals' }, + { id: 'f2', category: 'fundamentals' }, + { id: 'p1', category: 'phenomena' }, // no budget for phenomena + ]; + + const req: SelectionRequest = { + pool, + budgets: [{ category: 'fundamentals', count: 2 }], + totalCount: 2, + settings: { reviewMixRatio: 0 }, + now, + }; + + const result = await selectQuestionsV2(req, store); + // Only fundamentals items should be selected. + expect(result).toContain('f1'); + expect(result).toContain('f2'); + expect(result).not.toContain('p1'); + }); +}); + +describe('selectQuestionsV2 — stale fallback with mixed fresh and stale', () => { + it('skips fresh (null schedule) items in the stale path', async () => { + const store = new FakeStore(); + const now = 12_000_000_000; + + // q1 is reviewed (stale), q2 has no schedule (fresh). + // Neither is due. We want 2 items. reviewMixRatio=0 → no due budget. + // Fresh fill: q2 is fresh → selected first. + // Stale: q1 has a schedule and isn't selected yet → stale. + store.setSchedule('q1', { nextDue: now + 100_000_000, lastReviewed: now - 99_999 }); + + const pool = makePool(['q1', 'q2']); // q2 has no schedule (null) + const req = makeRequest(pool, 2, now, 0); + const result = await selectQuestionsV2(req, store); + + expect(result).toHaveLength(2); + expect(result).toContain('q1'); // from stale + expect(result).toContain('q2'); // from fresh + }); + + it('round-robin with unequal category sizes (one empties first)', async () => { + const store = new FakeStore(); + const now = 13_000_000_000; + + // 1 fundamentals item, 3 species items — all fresh. + const pool: SelectionRequest['pool'] = [ + { id: 'f1', category: 'fundamentals' }, + { id: 's1', category: 'species' }, + { id: 's2', category: 'species' }, + { id: 's3', category: 'species' }, + ]; + + const req: SelectionRequest = { + pool, + budgets: [ + { category: 'fundamentals', count: 1 }, + { category: 'species', count: 3 }, + ], + totalCount: 4, + settings: { reviewMixRatio: 0 }, + now, + }; + + const result = await selectQuestionsV2(req, store); + expect(result).toHaveLength(4); + // f1 should not be adjacent to another fundamentals (there are none), so + // order should mix: f1, s1, s2, s3 or s1, f1, s2, s3 etc. + // Just assert all are present. + expect(result).toContain('f1'); + expect(result).toContain('s1'); + expect(result).toContain('s2'); + expect(result).toContain('s3'); + }); +}); From 17870d443c44f97cc9b8cf950559bca779e9f710 Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:24:17 +0700 Subject: [PATCH 06/16] =?UTF-8?q?feat(study):=20T3=20=E2=80=94=207=20study?= =?UTF-8?q?=20UI=20components=20with=20tests=20and=20dev=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ConfidenceTap, RationalePanel, ScheduleBadge, UnvettedFlag, LearnQuizWidget, StudySettingsPanel, ExportImportPanel to src/components/quiz/study/. Each component is typed against study-types.ts contracts, uses only existing ui/ primitives (Button, Card, Badge, IconBox), and ships a full RTL test suite. Dev harness at src/pages/_dev/study-components.astro (DEV-gated 404 in production). Vitest + jsdom test infrastructure added; 89 tests pass. Design-token gaps documented in docs/design-needs.md. --- docs/design-needs.md | 74 + package-lock.json | 1310 ++++++++++++++++- package.json | 14 +- .../quiz/study/ConfidenceTap.test.tsx | 139 ++ src/components/quiz/study/ConfidenceTap.tsx | 141 ++ .../quiz/study/ExportImportPanel.test.tsx | 165 +++ .../quiz/study/ExportImportPanel.tsx | 190 +++ .../quiz/study/LearnQuizWidget.test.tsx | 318 ++++ src/components/quiz/study/LearnQuizWidget.tsx | 316 ++++ .../quiz/study/RationalePanel.test.tsx | 143 ++ src/components/quiz/study/RationalePanel.tsx | 171 +++ .../quiz/study/ScheduleBadge.test.tsx | 107 ++ src/components/quiz/study/ScheduleBadge.tsx | 64 + src/components/quiz/study/StudyDevHarness.tsx | 371 +++++ .../quiz/study/StudySettingsPanel.test.tsx | 154 ++ .../quiz/study/StudySettingsPanel.tsx | 181 +++ .../quiz/study/UnvettedFlag.test.tsx | 86 ++ src/components/quiz/study/UnvettedFlag.tsx | 75 + src/components/quiz/study/index.ts | 9 + src/pages/_dev/study-components.astro | 35 + src/test/setup.ts | 1 + vitest.config.ts | 19 + 22 files changed, 4033 insertions(+), 50 deletions(-) create mode 100644 docs/design-needs.md create mode 100644 src/components/quiz/study/ConfidenceTap.test.tsx create mode 100644 src/components/quiz/study/ConfidenceTap.tsx create mode 100644 src/components/quiz/study/ExportImportPanel.test.tsx create mode 100644 src/components/quiz/study/ExportImportPanel.tsx create mode 100644 src/components/quiz/study/LearnQuizWidget.test.tsx create mode 100644 src/components/quiz/study/LearnQuizWidget.tsx create mode 100644 src/components/quiz/study/RationalePanel.test.tsx create mode 100644 src/components/quiz/study/RationalePanel.tsx create mode 100644 src/components/quiz/study/ScheduleBadge.test.tsx create mode 100644 src/components/quiz/study/ScheduleBadge.tsx create mode 100644 src/components/quiz/study/StudyDevHarness.tsx create mode 100644 src/components/quiz/study/StudySettingsPanel.test.tsx create mode 100644 src/components/quiz/study/StudySettingsPanel.tsx create mode 100644 src/components/quiz/study/UnvettedFlag.test.tsx create mode 100644 src/components/quiz/study/UnvettedFlag.tsx create mode 100644 src/components/quiz/study/index.ts create mode 100644 src/pages/_dev/study-components.astro create mode 100644 src/test/setup.ts create mode 100644 vitest.config.ts diff --git a/docs/design-needs.md b/docs/design-needs.md new file mode 100644 index 0000000..d98d479 --- /dev/null +++ b/docs/design-needs.md @@ -0,0 +1,74 @@ +# Design Needs — Study Components T3 + +Cases encountered during T3 implementation where an existing primitive was +insufficient or absent, and the decision taken. + +--- + +## 1. Confidence-coloured button variants (ConfidenceTap) + +**Need:** Three distinct colour tones for unsure (red), fairly-sure (amber), +and certain (emerald) confidence buttons. + +**Available primitive:** `Button` with variants `primary | secondary | ghost | outline`. +None map semantically to the confidence spectrum. + +**Decision:** Used raw ` + ); + })} +
+ + ); +} diff --git a/src/components/quiz/study/ExportImportPanel.test.tsx b/src/components/quiz/study/ExportImportPanel.test.tsx new file mode 100644 index 0000000..e54eca1 --- /dev/null +++ b/src/components/quiz/study/ExportImportPanel.test.tsx @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ExportImportPanel } from './ExportImportPanel'; +import type { StudyStore } from '../../../lib/quiz/study-types'; + +// ── Minimal mock StudyStore ─────────────────────────────────────────────────── + +function makeMockStore(overrides: Partial = {}): StudyStore { + return { + appendResponse: vi.fn().mockResolvedValue(undefined), + getResponsesFor: vi.fn().mockResolvedValue([]), + getRecentResponses: vi.fn().mockResolvedValue([]), + getSchedule: vi.fn().mockResolvedValue(null), + updateSchedule: vi.fn().mockResolvedValue(undefined), + getDueItems: vi.fn().mockResolvedValue([]), + getProgress: vi.fn().mockResolvedValue({ + completedTopics: {} as any, + bestScores: {} as any, + totalQuizzes: 0, + totalCorrect: 0, + totalAttempted: 0, + lastActivity: 0, + }), + updateProgress: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn().mockResolvedValue(undefined), + updateSettings: vi.fn().mockResolvedValue(undefined), + exportAll: vi.fn().mockResolvedValue('{"version":1}'), + importAll: vi.fn().mockResolvedValue({ success: true, warnings: [] }), + ...overrides, + }; +} + +// ── Mock URL.createObjectURL (jsdom doesn't implement it) ───────────────────── + +beforeEach(() => { + (globalThis as any).URL.createObjectURL = vi.fn(() => 'blob:mock'); + (globalThis as any).URL.revokeObjectURL = vi.fn(); +}); + +describe('ExportImportPanel', () => { + // ── Render ───────────────────────────────────────────────────────────────── + + it('renders export and import buttons', () => { + render(); + expect(screen.getByRole('button', { name: /export study data/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /import/i })).toBeInTheDocument(); + }); + + it('renders a heading', () => { + render(); + expect(screen.getByRole('heading', { name: /export.*import/i })).toBeInTheDocument(); + }); + + // ── Export flow ──────────────────────────────────────────────────────────── + + it('calls store.exportAll() when export button is clicked', async () => { + const user = userEvent.setup(); + const store = makeMockStore(); + render(); + await user.click(screen.getByRole('button', { name: /export study data/i })); + await waitFor(() => expect(store.exportAll).toHaveBeenCalled()); + }); + + it('shows a success toast after successful export', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /export study data/i })); + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent(/exported successfully/i), + ); + }); + + it('shows an error toast when exportAll throws', async () => { + const user = userEvent.setup(); + const store = makeMockStore({ + exportAll: vi.fn().mockRejectedValue(new Error('disk full')), + }); + render(); + await user.click(screen.getByRole('button', { name: /export study data/i })); + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent(/export failed/i), + ); + }); + + // ── Import flow ──────────────────────────────────────────────────────────── + + it('shows a success toast after successful import', async () => { + const user = userEvent.setup(); + const store = makeMockStore({ + importAll: vi.fn().mockResolvedValue({ success: true, warnings: [] }), + }); + render(); + + const file = new File(['{"version":1}'], 'backup.json', { + type: 'application/json', + }); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(input, file); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent(/imported successfully/i), + ); + }); + + it('shows a warning toast when importAll returns warnings', async () => { + const user = userEvent.setup(); + const store = makeMockStore({ + importAll: vi.fn().mockResolvedValue({ + success: true, + warnings: ['Unknown field: foo'], + }), + }); + render(); + + const file = new File(['{"version":1}'], 'backup.json', { + type: 'application/json', + }); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(input, file); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent(/imported with warnings/i), + ); + expect(screen.getByRole('status')).toHaveTextContent(/Unknown field: foo/); + }); + + it('shows an error toast when importAll returns success=false', async () => { + const user = userEvent.setup(); + const store = makeMockStore({ + importAll: vi.fn().mockResolvedValue({ + success: false, + warnings: ['Invalid schema version'], + }), + }); + render(); + + const file = new File(['bad json'], 'backup.json', { + type: 'application/json', + }); + const input = document.querySelector('input[type="file"]') as HTMLInputElement; + await user.upload(input, file); + + await waitFor(() => + expect(screen.getByRole('status')).toHaveTextContent(/invalid schema version/i), + ); + }); + + // ── a11y ────────────────────────────────────────────────────────────────── + + it('toast has aria-live="polite"', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /export study data/i })); + await waitFor(() => screen.getByRole('status')); + expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite'); + }); + + it('file input has aria-hidden and tabIndex=-1', () => { + render(); + const input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('aria-hidden', 'true'); + expect(input).toHaveAttribute('tabindex', '-1'); + }); +}); diff --git a/src/components/quiz/study/ExportImportPanel.tsx b/src/components/quiz/study/ExportImportPanel.tsx new file mode 100644 index 0000000..28689e4 --- /dev/null +++ b/src/components/quiz/study/ExportImportPanel.tsx @@ -0,0 +1,190 @@ +/** + * ExportImportPanel — JSON export/import for localStorage study data. + * + * Export: calls store.exportAll() → triggers browser file download. + * Import: file-picker → reads JSON text → store.importAll() → success/error toast. + * + * Accepts `store: StudyStore` as a prop so the real implementation can be + * injected by the parent; tests use a mock. + * + * Uses Button from `@/components/ui`. + */ + +import { useRef, useState } from 'react'; +import { Download, Upload, CheckCircle2, AlertTriangle } from 'lucide-react'; +import type { StudyStore } from '../../../lib/quiz/study-types'; +import { Button } from '../../ui/Button'; +import { Card, CardHeader, CardTitle, CardContent, CardDescription } from '../../ui/Card'; +import { cn } from '../../ui/cn'; + +interface ExportImportPanelProps { + /** Injected store implementation (LocalStudyStore in prod, mock in dev/tests). */ + store: StudyStore; +} + +type ToastState = + | { kind: 'idle' } + | { kind: 'success'; message: string } + | { kind: 'warning'; message: string; warnings: string[] } + | { kind: 'error'; message: string }; + +export function ExportImportPanel({ store }: ExportImportPanelProps) { + const fileInputRef = useRef(null); + const [exportLoading, setExportLoading] = useState(false); + const [importLoading, setImportLoading] = useState(false); + const [toast, setToast] = useState({ kind: 'idle' }); + + const showToast = (state: ToastState, durationMs = 6000) => { + setToast(state); + setTimeout(() => setToast({ kind: 'idle' }), durationMs); + }; + + const handleExport = async () => { + setExportLoading(true); + try { + const json = await store.exportAll(); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + const date = new Date().toISOString().split('T')[0]; + a.href = url; + a.download = `gemmology-study-${date}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast({ kind: 'success', message: 'Study data exported successfully.' }); + } catch (err) { + showToast({ + kind: 'error', + message: 'Export failed. Please try again.', + }); + } finally { + setExportLoading(false); + } + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.currentTarget.files?.[0]; + if (!file) return; + + setImportLoading(true); + try { + const text = await file.text(); + const result = await store.importAll(text); + if (result.success && result.warnings.length === 0) { + showToast({ kind: 'success', message: 'Study data imported successfully.' }); + } else if (result.success) { + showToast({ + kind: 'warning', + message: 'Imported with warnings.', + warnings: result.warnings, + }); + } else { + showToast({ + kind: 'error', + message: result.warnings[0] ?? 'Import failed — the file may be invalid.', + }); + } + } catch { + showToast({ + kind: 'error', + message: 'Could not read the file. Make sure it is a valid JSON export.', + }); + } finally { + setImportLoading(false); + // Reset so the same file can be re-selected + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + return ( + + + Export & Import + + Back up your study history, schedule, and settings to a JSON file, or + restore a previous backup. + + + + + {/* Export button */} + + + {/* Import button */} + + + {/* Hidden file input */} + + + + {/* Toast notification */} + {toast.kind !== 'idle' && ( +
+ {toast.kind === 'success' && ( +
+ )} +
+ ); +} diff --git a/src/components/quiz/study/LearnQuizWidget.test.tsx b/src/components/quiz/study/LearnQuizWidget.test.tsx new file mode 100644 index 0000000..ef98977 --- /dev/null +++ b/src/components/quiz/study/LearnQuizWidget.test.tsx @@ -0,0 +1,318 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, within, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { LearnQuizWidget } from './LearnQuizWidget'; +import type { WidgetQuestion } from './LearnQuizWidget'; +import type { StudyStore } from '../../../lib/quiz/study-types'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const Q1: WidgetQuestion = { + id: 'q1', + questionText: 'What crystal system is diamond?', + options: ['Cubic', 'Hexagonal', 'Trigonal'], + correctAnswer: 'Cubic', + rationaleCorrect: 'Diamond crystallises in the cubic system with point group m3m.', + optionRationales: [ + { text: 'Cubic', isCorrect: true, rationale: 'Correct.' }, + { text: 'Hexagonal', isCorrect: false, rationale: 'Hexagonal has 6-fold symmetry.' }, + { text: 'Trigonal', isCorrect: false, rationale: 'Trigonal has 3-fold symmetry.' }, + ], +}; + +const Q2: WidgetQuestion = { + id: 'q2', + questionText: 'What is the RI range of corundum?', + options: ['1.544–1.553', '1.762–1.770', '1.718'], + correctAnswer: '1.762–1.770', + rationaleCorrect: 'Corundum has RI 1.762–1.770.', +}; + +const Q3: WidgetQuestion = { + id: 'q3', + questionText: 'Which gem shows asterism?', + options: ['Star ruby', 'Peridot', 'Spinel'], + correctAnswer: 'Star ruby', + rationaleCorrect: 'Star ruby shows asterism due to silk inclusions.', +}; + +const QUESTIONS = [Q1, Q2, Q3]; + +function makeMockStore(overrides: Partial = {}): StudyStore { + return { + appendResponse: vi.fn().mockResolvedValue(undefined), + getResponsesFor: vi.fn().mockResolvedValue([]), + getRecentResponses: vi.fn().mockResolvedValue([]), + getSchedule: vi.fn().mockResolvedValue(null), + updateSchedule: vi.fn().mockResolvedValue(undefined), + getDueItems: vi.fn().mockResolvedValue([]), + getProgress: vi.fn().mockResolvedValue({} as any), + updateProgress: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn().mockResolvedValue(undefined), + updateSettings: vi.fn().mockResolvedValue(undefined), + exportAll: vi.fn().mockResolvedValue('{}'), + importAll: vi.fn().mockResolvedValue({ success: true, warnings: [] }), + ...overrides, + }; +} + +// ── Helper: answer current question, pick confidence, submit ────────────────── + +async function answerQuestion( + user: ReturnType, + optionText: string, + confidence: 'q' | 'w' | 'e' = 'e', +) { + const optionBtn = screen.getByRole('radio', { name: new RegExp(optionText, 'i') }); + await user.click(optionBtn); + // Trigger confidence via keyboard shortcut (userEvent wraps in act) + await user.keyboard(confidence); + // Wait for submit button to be enabled + const submitBtn = await screen.findByRole('button', { name: /submit/i }); + await user.click(submitBtn); +} + +describe('LearnQuizWidget', () => { + // ── pretestEnabled=false ─────────────────────────────────────────────────── + + it('renders nothing when pretestEnabled=false', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when questions array is empty', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + // ── Initial render ───────────────────────────────────────────────────────── + + it('renders the first question text', () => { + render( + , + ); + expect(screen.getByText(Q1.questionText)).toBeInTheDocument(); + }); + + it('shows question counter "1 of 3"', () => { + render( + , + ); + expect(screen.getByText(/1 of 3/i)).toBeInTheDocument(); + }); + + it('renders answer options as radio buttons', () => { + render( + , + ); + for (const opt of Q1.options) { + expect(screen.getByRole('radio', { name: new RegExp(opt, 'i') })).toBeInTheDocument(); + } + }); + + it('Submit button is absent before selecting an option', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: /submit/i })).not.toBeInTheDocument(); + }); + + // ── Option → confidence → submit flow ───────────────────────────────────── + + it('shows ConfidenceTap after selecting an option', async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('radio', { name: /cubic/i })); + expect(screen.getByRole('group', { name: /how confident/i })).toBeInTheDocument(); + }); + + it('Submit button is disabled before choosing confidence', async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('radio', { name: /cubic/i })); + // Submit button appears but is disabled + const submitBtn = screen.getByRole('button', { name: /submit/i }); + expect(submitBtn).toBeDisabled(); + }); + + it('Submit button is enabled after option + confidence selected', async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('radio', { name: /cubic/i })); + await user.keyboard('e'); // certain — triggers ConfidenceTap shortcut + // Button must now be enabled + const submitBtn = await screen.findByRole('button', { name: /submit/i }); + expect(submitBtn).not.toBeDisabled(); + }); + + it('shows RationalePanel after submitting', async () => { + const user = userEvent.setup(); + render( + , + ); + await answerQuestion(user, 'Cubic'); + expect( + screen.getByRole('region', { name: /answer explanation/i }), + ).toBeInTheDocument(); + }); + + it('shows "Next question" button after submitting non-final question', async () => { + const user = userEvent.setup(); + render( + , + ); + await answerQuestion(user, 'Cubic'); + expect(screen.getByRole('button', { name: /next question/i })).toBeInTheDocument(); + }); + + // ── Q2 advance ──────────────────────────────────────────────────────────── + + it('advances to question 2 after clicking "Next question"', async () => { + const user = userEvent.setup(); + render( + , + ); + await answerQuestion(user, 'Cubic'); + await user.click(screen.getByRole('button', { name: /next question/i })); + expect(screen.getByText(Q2.questionText)).toBeInTheDocument(); + expect(screen.getByText(/2 of 3/i)).toBeInTheDocument(); + }); + + // ── Q3 and completion ───────────────────────────────────────────────────── + + it('shows "Continue to article" button on the last question after submit', async () => { + const user = userEvent.setup(); + render( + , + ); + await answerQuestion(user, 'Cubic'); + await user.click(screen.getByRole('button', { name: /next question/i })); + await answerQuestion(user, '1.762'); + await user.click(screen.getByRole('button', { name: /next question/i })); + await answerQuestion(user, 'Star ruby'); + expect(screen.getByRole('button', { name: /continue to article/i })).toBeInTheDocument(); + }); + + it('shows completion message after clicking "Continue to article"', async () => { + const user = userEvent.setup(); + render( + , + ); + // Full flow + await answerQuestion(user, 'Cubic'); + await user.click(screen.getByRole('button', { name: /next question/i })); + await answerQuestion(user, '1.762'); + await user.click(screen.getByRole('button', { name: /next question/i })); + await answerQuestion(user, 'Star ruby'); + await user.click(screen.getByRole('button', { name: /continue to article/i })); + + expect(screen.getByText(/pretest complete/i)).toBeInTheDocument(); + }); + + it('final CTA mentions how many were correct', async () => { + const user = userEvent.setup(); + render( + , + ); + await answerQuestion(user, 'Cubic'); // correct + await user.click(screen.getByRole('button', { name: /next question/i })); + await answerQuestion(user, '1.544'); // wrong + await user.click(screen.getByRole('button', { name: /next question/i })); + await answerQuestion(user, 'Star ruby'); // correct + await user.click(screen.getByRole('button', { name: /continue to article/i })); + + // 2 correct out of 3 + expect(screen.getByText(/2 of 3 correct/i)).toBeInTheDocument(); + }); + + // ── Store integration ───────────────────────────────────────────────────── + + it('calls store.appendResponse with mode="pretest" after submitting', async () => { + const user = userEvent.setup(); + const store = makeMockStore(); + render( + , + ); + await answerQuestion(user, 'Cubic'); + await waitFor(() => { + expect(store.appendResponse).toHaveBeenCalledWith( + expect.objectContaining({ mode: 'pretest', questionId: 'q1' }), + ); + }); + }); + + it('does not crash when store is undefined', async () => { + const user = userEvent.setup(); + render( + , + ); + await answerQuestion(user, 'Cubic'); + // No error thrown + expect(screen.getByRole('region', { name: /answer explanation/i })).toBeInTheDocument(); + }); + + // ── Limits to MAX_QUESTIONS=3 ───────────────────────────────────────────── + + it('uses at most 3 questions even if more are passed', () => { + const extraQ: WidgetQuestion = { + id: 'q4', + questionText: 'Extra question?', + options: ['Yes', 'No'], + correctAnswer: 'Yes', + rationaleCorrect: 'Yes.', + }; + render( + , + ); + expect(screen.getByText(/1 of 3/i)).toBeInTheDocument(); + }); + + // ── a11y ────────────────────────────────────────────────────────────────── + + it('widget has role="complementary" via the aside landmark', () => { + render( + , + ); + // Astro renders aside, RTL sees it + expect(screen.getByRole('complementary')).toBeInTheDocument(); + }); + + it('option buttons have role="radio" for a11y', () => { + render( + , + ); + const radios = screen.getAllByRole('radio'); + expect(radios.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/src/components/quiz/study/LearnQuizWidget.tsx b/src/components/quiz/study/LearnQuizWidget.tsx new file mode 100644 index 0000000..f21a06e --- /dev/null +++ b/src/components/quiz/study/LearnQuizWidget.tsx @@ -0,0 +1,316 @@ +/** + * LearnQuizWidget — 3-question pretest widget shown above a learn article. + * + * Flow (per V1-PLAN §A.4 and §7 Phase 3): + * 1. Show question → user picks option + * 2. User selects confidence (ConfidenceTap) + * 3. Submit → reveal rationale (RationalePanel) + * 4. "Next question" → repeat for up to 3 questions + * 5. After final question → show "Now read the article below" CTA + * + * Props: + * - `slug` — learn article slug (used for analytics tagging) + * - `pretestEnabled` — if false, renders nothing + * - `questions` — up to 3 pre-fetched questions (from curated bank or auto-gen) + * - `store` — StudyStore interface (stubbed in dev/tests) + * + * Uses Card, Button primitives; ConfidenceTap and RationalePanel sub-components. + */ + +import { useState, useId } from 'react'; +import { BookOpen, ChevronRight, CheckCircle2 } from 'lucide-react'; +import type { StudyStore, Confidence } from '../../../lib/quiz/study-types'; +import { Card, CardContent } from '../../ui/Card'; +import { Button } from '../../ui/Button'; +import { Badge } from '../../ui/Badge'; +import { cn } from '../../ui/cn'; +import { ConfidenceTap } from './ConfidenceTap'; +import { RationalePanel } from './RationalePanel'; +import type { OptionRationale } from './RationalePanel'; + +/** Minimal question shape the widget needs — avoids importing full Question type */ +export interface WidgetQuestion { + id: string; + questionText: string; + /** All option texts (2–5). */ + options: string[]; + /** The text of the correct option. */ + correctAnswer: string; + /** Overall rationale for the correct answer. */ + rationaleCorrect?: string; + /** Per-option rationales (same length as options). */ + optionRationales?: OptionRationale[]; + /** Whether this question is auto-generated and not expert-reviewed. */ + unvetted?: boolean; +} + +interface LearnQuizWidgetProps { + /** Slug of the article this widget appears above (used for tagging responses). */ + slug: string; + /** Master on/off switch. When false the widget is invisible. */ + pretestEnabled: boolean; + /** Up to 3 questions (caller handles fetching/fallback). */ + questions: WidgetQuestion[]; + /** StudyStore for persisting responses. Pass a mock in dev/tests. */ + store?: StudyStore; +} + +type QuestionPhase = 'selecting' | 'confidence' | 'submitted'; + +interface QuestionState { + selectedOption: string | null; + confidence: Confidence | null; + phase: QuestionPhase; +} + +const MAX_QUESTIONS = 3; + +function freshQuestionState(): QuestionState { + return { selectedOption: null, confidence: null, phase: 'selecting' }; +} + +export function LearnQuizWidget({ + slug, + pretestEnabled, + questions, + store, +}: LearnQuizWidgetProps) { + const legendId = useId(); + const visibleQuestions = questions.slice(0, MAX_QUESTIONS); + + const [currentIndex, setCurrentIndex] = useState(0); + const [questionState, setQuestionState] = useState(freshQuestionState); + const [done, setDone] = useState(false); + const [correctCount, setCorrectCount] = useState(0); + + if (!pretestEnabled || visibleQuestions.length === 0) return null; + + const currentQuestion = visibleQuestions[currentIndex]; + const { selectedOption, confidence, phase } = questionState; + + const isSubmitted = phase === 'submitted'; + const isLast = currentIndex === visibleQuestions.length - 1; + + const isCorrect = + selectedOption !== null && selectedOption === currentQuestion.correctAnswer; + + const canShowConfidence = phase === 'confidence'; + const canSubmit = + phase === 'confidence' && selectedOption !== null && confidence !== null; + + const handleOptionSelect = (opt: string) => { + if (isSubmitted) return; + setQuestionState((s) => ({ + ...s, + selectedOption: opt, + // Move to confidence phase on first selection + phase: s.phase === 'selecting' ? 'confidence' : s.phase, + })); + }; + + const handleConfidenceChange = (c: Confidence) => { + setQuestionState((s) => ({ ...s, confidence: c })); + }; + + const handleSubmit = async () => { + if (!canSubmit) return; + setQuestionState((s) => ({ ...s, phase: 'submitted' })); + if (isCorrect) setCorrectCount((n) => n + 1); + + // Persist to store if provided + if (store && confidence) { + try { + await store.appendResponse({ + questionId: currentQuestion.id, + timestamp: Date.now(), + correct: isCorrect, + confidence, + timeMs: 0, + mode: 'pretest', + optionChosen: selectedOption ?? undefined, + sessionId: `pretest-${slug}-${Date.now()}`, + }); + } catch { + // Fire-and-forget; widget must not break on store error + } + } + }; + + const handleNext = () => { + if (isLast) { + setDone(true); + } else { + setCurrentIndex((i) => i + 1); + setQuestionState(freshQuestionState()); + } + }; + + // --- Completed state --- + if (done) { + return ( + + ); + } + + // --- Active widget --- + const optionRationales: OptionRationale[] = currentQuestion.optionRationales?.length + ? currentQuestion.optionRationales + : currentQuestion.options.map((text) => ({ + text, + isCorrect: text === currentQuestion.correctAnswer, + rationale: '', + })); + + const userPickedIndex = selectedOption + ? currentQuestion.options.indexOf(selectedOption) + : undefined; + + return ( + + ); +} diff --git a/src/components/quiz/study/RationalePanel.test.tsx b/src/components/quiz/study/RationalePanel.test.tsx new file mode 100644 index 0000000..660837b --- /dev/null +++ b/src/components/quiz/study/RationalePanel.test.tsx @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RationalePanel } from './RationalePanel'; +import type { OptionRationale } from './RationalePanel'; + +const RATIONALES: OptionRationale[] = [ + { text: 'Ruby', isCorrect: true, rationale: 'All readings match corundum.' }, + { text: 'Spinel', isCorrect: false, rationale: 'Spinel is isotropic.' }, + { text: 'Pyrope', isCorrect: false, rationale: 'Pyrope SG is lower.' }, +]; + +describe('RationalePanel', () => { + // ── show=false ───────────────────────────────────────────────────────────── + + it('renders nothing when show=false', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + // ── show=true, correct answer ────────────────────────────────────────────── + + it('renders the correct rationale text when shown', () => { + render( + , + ); + expect(screen.getByText('Great diagnostic chain.')).toBeInTheDocument(); + }); + + it('shows "Correct — here is why" heading when correct=true', () => { + render( + , + ); + expect(screen.getByText(/correct — here is why/i)).toBeInTheDocument(); + }); + + it('shows "Not quite — here is why" heading when correct=false', () => { + render( + , + ); + expect(screen.getByText(/not quite — here is why/i)).toBeInTheDocument(); + }); + + // ── Collapse / expand ────────────────────────────────────────────────────── + + it('starts expanded (body visible)', () => { + render( + , + ); + expect(screen.getByText('Explanation here.')).toBeVisible(); + }); + + it('collapses when the header button is clicked', async () => { + const user = userEvent.setup(); + render( + , + ); + const toggle = screen.getByRole('button'); + await user.click(toggle); + // After collapse, the body div has `hidden` attribute + const body = document.getElementById('rationale-body'); + expect(body).toHaveAttribute('hidden'); + }); + + it('re-expands when clicked again', async () => { + const user = userEvent.setup(); + render( + , + ); + const toggle = screen.getByRole('button'); + await user.click(toggle); // collapse + await user.click(toggle); // expand + const body = document.getElementById('rationale-body'); + expect(body).not.toHaveAttribute('hidden'); + }); + + it('toggle button has aria-expanded reflecting state', async () => { + const user = userEvent.setup(); + render( + , + ); + const toggle = screen.getByRole('button'); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await user.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + }); + + // ── Per-option rationales ────────────────────────────────────────────────── + + it('renders all option rationales when provided', () => { + render( + , + ); + expect(screen.getByText('All readings match corundum.')).toBeInTheDocument(); + expect(screen.getByText('Spinel is isotropic.')).toBeInTheDocument(); + expect(screen.getByText('Pyrope SG is lower.')).toBeInTheDocument(); + }); + + it('marks the user-picked incorrect option with "(your choice)"', () => { + render( + , + ); + expect(screen.getByText('(your choice)')).toBeInTheDocument(); + }); + + it('renders without optionRationales prop without errors', () => { + render( + , + ); + expect(screen.getByText('test')).toBeInTheDocument(); + }); + + // ── a11y ────────────────────────────────────────────────────────────────── + + it('has role="region" with an accessible label', () => { + render(); + expect( + screen.getByRole('region', { name: /answer explanation/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/quiz/study/RationalePanel.tsx b/src/components/quiz/study/RationalePanel.tsx new file mode 100644 index 0000000..ece557d --- /dev/null +++ b/src/components/quiz/study/RationalePanel.tsx @@ -0,0 +1,171 @@ +/** + * RationalePanel — collapsible post-submit feedback panel. + * + * Shows the `rationaleCorrect` summary plus per-option rationales, colour-coded + * by whether each option is correct, incorrect-chosen, or merely incorrect. + * + * Uses Card, CardHeader, CardTitle, CardContent from `@/components/ui`. + */ + +import { useState } from 'react'; +import { ChevronDown, ChevronUp, CheckCircle2, XCircle } from 'lucide-react'; +import { Card, CardContent } from '../../ui/Card'; +import { cn } from '../../ui/cn'; + +export interface OptionRationale { + /** Display text of this option. */ + text: string; + /** Whether this is the correct answer. */ + isCorrect: boolean; + /** Per-distractor or correct-answer rationale. */ + rationale: string; +} + +interface RationalePanelProps { + /** Whether the user answered correctly. */ + correct: boolean; + /** The overall rationale for the correct answer. */ + rationaleCorrect: string; + /** Per-option rationales (all options, correct + incorrect). */ + optionRationales?: OptionRationale[]; + /** Index of the option the user picked (used for highlighting). */ + userPickedIndex?: number; + /** Whether the panel should be visible (caller controls show/hide). */ + show: boolean; +} + +export function RationalePanel({ + correct, + rationaleCorrect, + optionRationales = [], + userPickedIndex, + show, +}: RationalePanelProps) { + const [expanded, setExpanded] = useState(true); + + if (!show) return null; + + const toggleLabel = expanded ? 'Collapse explanation' : 'Expand explanation'; + + return ( +
+ {/* Header row — always visible */} + + + {/* Collapsible body */} + +
+ ); +} diff --git a/src/components/quiz/study/ScheduleBadge.test.tsx b/src/components/quiz/study/ScheduleBadge.test.tsx new file mode 100644 index 0000000..2f3fd52 --- /dev/null +++ b/src/components/quiz/study/ScheduleBadge.test.tsx @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ScheduleBadge } from './ScheduleBadge'; +import type { ScheduleEntry } from '../../../lib/quiz/study-types'; + +const NOW = 1_746_748_800_000; // fixed epoch for deterministic tests + +function makeEntry(overrides: Partial = {}): ScheduleEntry { + return { + questionId: 'test-q', + nextDue: NOW + 86_400_000 * 7, // due in 7 days + intervalDays: 7, + easeFactor: 2.5, + repetitions: 2, + lapses: 0, + lastReviewed: NOW - 86_400_000, + totalReviews: 3, + ...overrides, + }; +} + +describe('ScheduleBadge', () => { + // ── null / never-seen entry ──────────────────────────────────────────────── + + it('renders "New" when entry is null', () => { + render(); + expect(screen.getByText('New')).toBeInTheDocument(); + }); + + it('renders "New" when totalReviews is 0', () => { + render(); + expect(screen.getByText('New')).toBeInTheDocument(); + }); + + it('"New" badge has an appropriate aria-label', () => { + render(); + expect(screen.getByLabelText(/new — never seen before/i)).toBeInTheDocument(); + }); + + // ── Due state ───────────────────────────────────────────────────────────── + + it('renders "Due" when nextDue is in the past', () => { + render( + , + ); + expect(screen.getByText('Due')).toBeInTheDocument(); + }); + + it('renders "Due" when nextDue equals now', () => { + render(); + expect(screen.getByText('Due')).toBeInTheDocument(); + }); + + it('"Due" badge has an appropriate aria-label', () => { + render( + , + ); + expect(screen.getByLabelText(/due for review/i)).toBeInTheDocument(); + }); + + // ── Mastered state ──────────────────────────────────────────────────────── + + it('renders "Mastered (7 d)" when due in 7 days', () => { + render( + , + ); + expect(screen.getByText('Mastered (7 d)')).toBeInTheDocument(); + }); + + it('renders "Mastered (1 d)" with singular day label', () => { + render( + , + ); + expect(screen.getByText('Mastered (1 d)')).toBeInTheDocument(); + }); + + it('"Mastered" badge has an appropriate aria-label mentioning days', () => { + render( + , + ); + expect(screen.getByLabelText(/mastered, due in 14 days/i)).toBeInTheDocument(); + }); + + // ── Edge: far-future date ───────────────────────────────────────────────── + + it('handles very large intervals gracefully', () => { + render( + , + ); + expect(screen.getByText('Mastered (365 d)')).toBeInTheDocument(); + }); +}); diff --git a/src/components/quiz/study/ScheduleBadge.tsx b/src/components/quiz/study/ScheduleBadge.tsx new file mode 100644 index 0000000..f7f3fdf --- /dev/null +++ b/src/components/quiz/study/ScheduleBadge.tsx @@ -0,0 +1,64 @@ +/** + * ScheduleBadge — compact SM-2 schedule status indicator. + * + * Variants: + * - null entry → "New" (crystal/blue) + * - due now → "Due" (amber) + * - scheduled → "Mastered (N d)" (emerald) + * + * Uses the `Badge` primitive from `@/components/ui`. + */ + +import type { ScheduleEntry } from '../../../lib/quiz/study-types'; +import { Badge } from '../../ui/Badge'; + +interface ScheduleBadgeProps { + /** SM-2 schedule entry, or null when the question has never been seen. */ + entry: ScheduleEntry | null; + /** Override "now" for testing purposes (Unix ms). */ + now?: number; +} + +function daysUntil(nextDueMs: number, nowMs: number): number { + return Math.round((nextDueMs - nowMs) / 86_400_000); +} + +export function ScheduleBadge({ entry, now: nowOverride }: ScheduleBadgeProps) { + const now = nowOverride ?? Date.now(); + + if (!entry || entry.totalReviews === 0) { + return ( + + New + + ); + } + + const isDue = entry.nextDue <= now; + + if (isDue) { + return ( + + Due + + ); + } + + const days = daysUntil(entry.nextDue, now); + const label = + days === 1 ? 'Mastered (1 d)' : `Mastered (${days} d)`; + + return ( + + {label} + + ); +} diff --git a/src/components/quiz/study/StudyDevHarness.tsx b/src/components/quiz/study/StudyDevHarness.tsx new file mode 100644 index 0000000..371e579 --- /dev/null +++ b/src/components/quiz/study/StudyDevHarness.tsx @@ -0,0 +1,371 @@ +/** + * Dev harness component — only used in `src/pages/_dev/study-components.astro`. + * Not exported from the main index; never imported in production code. + */ + +import { useState } from 'react'; +import { DEFAULT_STUDY_SETTINGS } from '../../../lib/quiz/study-types'; +import type { StudySettings, StudyStore } from '../../../lib/quiz/study-types'; +import { ConfidenceTap } from './ConfidenceTap'; +import { RationalePanel } from './RationalePanel'; +import type { OptionRationale } from './RationalePanel'; +import { ScheduleBadge } from './ScheduleBadge'; +import { UnvettedFlag } from './UnvettedFlag'; +import { StudySettingsPanel } from './StudySettingsPanel'; +import { ExportImportPanel } from './ExportImportPanel'; +import { LearnQuizWidget } from './LearnQuizWidget'; +import type { WidgetQuestion } from './LearnQuizWidget'; + +// ── Mock store (success) ────────────────────────────────────────────────────── + +function makeMockStore(exportFails = false): StudyStore { + return { + appendResponse: async () => {}, + getResponsesFor: async () => [], + getRecentResponses: async () => [], + getSchedule: async () => null, + updateSchedule: async () => {}, + getDueItems: async () => [], + getProgress: async () => ({ + completedTopics: {} as any, + bestScores: {} as any, + totalQuizzes: 0, + totalCorrect: 0, + totalAttempted: 0, + lastActivity: 0, + }), + updateProgress: async () => {}, + getSettings: async () => DEFAULT_STUDY_SETTINGS, + updateSettings: async () => {}, + exportAll: exportFails + ? async () => { throw new Error('Simulated disk full'); } + : async () => JSON.stringify({ version: 1, mock: true }, null, 2), + importAll: async () => ({ success: true, warnings: [] }), + }; +} + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const NOW = Date.now(); + +const SCHEDULE_ENTRIES = { + neverSeen: null, + due: { + questionId: 'x', + nextDue: NOW - 1000, + intervalDays: 1, + easeFactor: 2.5, + repetitions: 1, + lapses: 0, + lastReviewed: NOW - 86_400_000, + totalReviews: 2, + }, + mastered7: { + questionId: 'y', + nextDue: NOW + 86_400_000 * 7, + intervalDays: 7, + easeFactor: 2.6, + repetitions: 3, + lapses: 0, + lastReviewed: NOW - 86_400_000, + totalReviews: 5, + }, + mastered30: { + questionId: 'z', + nextDue: NOW + 86_400_000 * 30, + intervalDays: 30, + easeFactor: 2.8, + repetitions: 6, + lapses: 1, + lastReviewed: NOW - 86_400_000, + totalReviews: 10, + }, +} as const; + +const OPTION_RATIONALES: OptionRationale[] = [ + { + text: 'Natural ruby (corundum)', + isCorrect: true, + rationale: 'RI 1.762–1.770, SG 4.00, uniaxial negative — all diagnostic of corundum.', + }, + { + text: 'Spinel', + isCorrect: false, + rationale: 'Spinel is isotropic (single RI ≈ 1.718) — no uniaxial reading possible.', + }, + { + text: 'Pyrope garnet', + isCorrect: false, + rationale: 'Pyrope is isotropic and SG 3.7–3.9, not 4.00.', + }, +]; + +const MOCK_QUESTIONS: WidgetQuestion[] = [ + { + id: 'q1', + questionText: + 'A polished red stone gives RI 1.762–1.770, SG 4.00, uniaxial negative. What is the most likely identification?', + options: ['Natural ruby (corundum)', 'Spinel', 'Pyrope garnet'], + correctAnswer: 'Natural ruby (corundum)', + rationaleCorrect: + 'The diagnostic chain is optic character first (eliminates spinel and pyrope as isotropic), then SG (eliminates remaining alternatives at 4.00).', + optionRationales: OPTION_RATIONALES, + }, + { + id: 'q2', + questionText: 'Which crystal system is diamond?', + options: ['Cubic', 'Hexagonal', 'Trigonal'], + correctAnswer: 'Cubic', + rationaleCorrect: 'Diamond crystallises in the cubic (isometric) system with point group m3m.', + optionRationales: [ + { text: 'Cubic', isCorrect: true, rationale: 'Correct — m3m symmetry.' }, + { text: 'Hexagonal', isCorrect: false, rationale: '6-fold symmetry axis; does not apply to diamond.' }, + { text: 'Trigonal', isCorrect: false, rationale: '3-fold symmetry; quartz and corundum, not diamond.' }, + ], + }, + { + id: 'q3', + questionText: 'What causes asterism in star ruby?', + options: ['Silk (rutile needles)', 'Liquid inclusions', 'Growth planes'], + correctAnswer: 'Silk (rutile needles)', + rationaleCorrect: + 'Rutile needle inclusions oriented along three sets of planes at 120° produce the six-rayed star by reflected light.', + optionRationales: [ + { text: 'Silk (rutile needles)', isCorrect: true, rationale: 'Three sets of intersecting rutile needles at 60°/120°.' }, + { text: 'Liquid inclusions', isCorrect: false, rationale: 'Liquid inclusions produce internal reflections, not asterism.' }, + { text: 'Growth planes', isCorrect: false, rationale: 'Growth planes can cause colour zoning, not asterism.' }, + ], + }, +]; + +const MOCK_QUESTIONS_UNVETTED: WidgetQuestion[] = [ + { + ...MOCK_QUESTIONS[0], + id: 'q1-unvetted', + unvetted: true, + }, +]; + +// ── Section wrapper ─────────────────────────────────────────────────────────── + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Sub({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+

{label}

+ {children} +
+ ); +} + +// ── Interactive ConfidenceTap ───────────────────────────────────────────────── + +function InteractiveConfidenceTap() { + const [value, setValue] = useState<'unsure' | 'fairly-sure' | 'certain' | null>(null); + return ( +
+ +

+ Selected: {value ?? 'null'} — try keyboard Q/W/E +

+
+ ); +} + +// ── Interactive StudySettingsPanel ──────────────────────────────────────────── + +function InteractiveSettings() { + const [settings, setSettings] = useState({ ...DEFAULT_STUDY_SETTINGS }); + return ( +
+ setSettings((s) => ({ ...s, ...patch }))} + /> +
+        {JSON.stringify(settings, null, 2)}
+      
+
+ ); +} + +// ── Main harness component ──────────────────────────────────────────────────── + +export function StudyDevHarness() { + return ( +
+
+

Study Components — Dev Harness

+

+ Only served in development (import.meta.env.DEV). All components rendered + with mocked data. +

+
+ + {/* ── ConfidenceTap ─────────────────────────────────────────────────── */} +
+
+ + {}} /> + + + {}} /> + + + {}} /> + + + {}} /> + + + {}} disabled={true} /> + + + + +
+
+ + {/* ── RationalePanel ────────────────────────────────────────────────── */} +
+
+ + + + + + + + + + + +

^ Nothing above

+
+
+
+ + {/* ── ScheduleBadge ─────────────────────────────────────────────────── */} +
+
+
+ null entry + +
+
+ totalReviews=0 + +
+
+ due now + +
+
+ 7 days + +
+
+ 30 days + +
+
+
+ + {/* ── UnvettedFlag ──────────────────────────────────────────────────── */} +
+
+ + + + + +

^ Nothing above

+
+
+
+ + {/* ── StudySettingsPanel ────────────────────────────────────────────── */} +
+ +
+ + {/* ── ExportImportPanel ─────────────────────────────────────────────── */} +
+
+ + + + + + +
+
+ + {/* ── LearnQuizWidget ───────────────────────────────────────────────── */} +
+
+ + + + + +

^ Nothing above

+
+ + + + + + +
+
+
+ ); +} diff --git a/src/components/quiz/study/StudySettingsPanel.test.tsx b/src/components/quiz/study/StudySettingsPanel.test.tsx new file mode 100644 index 0000000..7ce7b9c --- /dev/null +++ b/src/components/quiz/study/StudySettingsPanel.test.tsx @@ -0,0 +1,154 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { StudySettingsPanel } from './StudySettingsPanel'; +import type { StudySettings } from '../../../lib/quiz/study-types'; +import { DEFAULT_STUDY_SETTINGS } from '../../../lib/quiz/study-types'; + +const defaultSettings: StudySettings = { ...DEFAULT_STUDY_SETTINGS }; + +describe('StudySettingsPanel', () => { + // ── Render ───────────────────────────────────────────────────────────────── + + it('renders all four setting controls', () => { + render( {}} />); + expect(screen.getByRole('switch', { name: /require confidence/i })).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: /show rationale/i })).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: /review mix/i })).toBeInTheDocument(); + expect(screen.getByRole('spinbutton', { name: /default question count/i })).toBeInTheDocument(); + }); + + it('reflects requireConfidence=true', () => { + render( + {}} + />, + ); + expect(screen.getByRole('switch', { name: /require confidence/i })).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('reflects requireConfidence=false', () => { + render( + {}} + />, + ); + expect(screen.getByRole('switch', { name: /require confidence/i })).toHaveAttribute( + 'aria-checked', + 'false', + ); + }); + + it('reflects showRationaleOnSubmit=false', () => { + render( + {}} + />, + ); + expect(screen.getByRole('switch', { name: /show rationale/i })).toHaveAttribute( + 'aria-checked', + 'false', + ); + }); + + it('reflects reviewMixRatio in the slider and percentage label', () => { + render( + {}} + />, + ); + const slider = screen.getByRole('slider', { name: /review mix/i }); + expect(slider).toHaveValue('0.4'); + // Percentage label visible somewhere + expect(screen.getByText(/40%/)).toBeInTheDocument(); + }); + + it('reflects preferredQuestionCount', () => { + render( + {}} + />, + ); + expect(screen.getByRole('spinbutton', { name: /default question count/i })).toHaveValue(15); + }); + + // ── Interaction ──────────────────────────────────────────────────────────── + + it('calls onChange with { requireConfidence: false } when toggled off', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('switch', { name: /require confidence/i })); + expect(onChange).toHaveBeenCalledWith({ requireConfidence: false }); + }); + + it('calls onChange with { showRationaleOnSubmit: false } when toggled off', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + await user.click(screen.getByRole('switch', { name: /show rationale/i })); + expect(onChange).toHaveBeenCalledWith({ showRationaleOnSubmit: false }); + }); + + it('calls onChange with updated reviewMixRatio when slider moves', async () => { + const onChange = vi.fn(); + const { fireEvent: fe } = await import('@testing-library/react'); + render( + , + ); + const slider = screen.getByRole('slider', { name: /review mix/i }); + fe.change(slider, { target: { value: '0.5' } }); + expect(onChange).toHaveBeenCalledWith({ reviewMixRatio: 0.5 }); + }); + + it('calls onChange with updated preferredQuestionCount on valid input', async () => { + const onChange = vi.fn(); + const { fireEvent: fe } = await import('@testing-library/react'); + render( + , + ); + const input = screen.getByRole('spinbutton', { name: /default question count/i }); + fe.change(input, { target: { value: '20' } }); + expect(onChange).toHaveBeenCalledWith({ preferredQuestionCount: 20 }); + }); + + // ── a11y attributes ──────────────────────────────────────────────────────── + + it('toggle switches have role="switch"', () => { + render( {}} />); + const switches = screen.getAllByRole('switch'); + expect(switches.length).toBeGreaterThanOrEqual(2); + }); + + it('slider has aria-valuemin, aria-valuemax, aria-valuenow', () => { + render( {}} />); + const slider = screen.getByRole('slider', { name: /review mix/i }); + expect(slider).toHaveAttribute('aria-valuemin', '0'); + expect(slider).toHaveAttribute('aria-valuemax', '1'); + expect(slider).toHaveAttribute('aria-valuenow'); + }); +}); diff --git a/src/components/quiz/study/StudySettingsPanel.tsx b/src/components/quiz/study/StudySettingsPanel.tsx new file mode 100644 index 0000000..1968c1b --- /dev/null +++ b/src/components/quiz/study/StudySettingsPanel.tsx @@ -0,0 +1,181 @@ +/** + * StudySettingsPanel — user-tunable study behaviour toggles. + * + * Surfaces the four `StudySettings` fields: + * - requireConfidence (toggle) + * - showRationaleOnSubmit (toggle) + * - reviewMixRatio (slider 0–1) + * - preferredQuestionCount (number input) + * + * Props are fully controlled; parent owns state via onChange(patch). + * Uses Card, CardHeader, CardTitle, CardContent from `@/components/ui`. + */ + +import type { StudySettings } from '../../../lib/quiz/study-types'; +import { Card, CardHeader, CardTitle, CardContent } from '../../ui/Card'; +import { cn } from '../../ui/cn'; + +interface StudySettingsPanelProps { + /** Current settings value. */ + value: StudySettings; + /** Partial patch emitted on every change. */ + onChange: (patch: Partial) => void; +} + +interface ToggleRowProps { + id: string; + label: string; + description: string; + checked: boolean; + onToggle: (next: boolean) => void; +} + +function ToggleRow({ id, label, description, checked, onToggle }: ToggleRowProps) { + return ( +
+
+ +

{description}

+
+ {/* Toggle switch */} + +
+ ); +} + +export function StudySettingsPanel({ value, onChange }: StudySettingsPanelProps) { + const reviewMixPercent = Math.round(value.reviewMixRatio * 100); + + return ( + + + Study settings + + + + {/* Require confidence */} + onChange({ requireConfidence: v })} + /> + + {/* Show rationale on submit */} + onChange({ showRationaleOnSubmit: v })} + /> + + {/* Review mix ratio */} +
+ +

+ Fraction of due (spaced-repetition) items vs new questions in each session. +

+ + onChange({ reviewMixRatio: parseFloat(e.currentTarget.value) }) + } + aria-valuemin={0} + aria-valuemax={1} + aria-valuenow={value.reviewMixRatio} + aria-valuetext={`${reviewMixPercent}% review`} + className={cn( + 'w-full h-2 rounded-full appearance-none cursor-pointer', + 'bg-slate-200 dark:bg-slate-700', + 'accent-crystal-600 dark:accent-crystal-500', + )} + /> +
+ All new + All review +
+
+ + {/* Preferred question count */} +
+ +

+ Pre-filled in the quiz setup screen. +

+ { + const n = parseInt(e.currentTarget.value, 10); + if (!isNaN(n) && n >= 3 && n <= 50) { + onChange({ preferredQuestionCount: n }); + } + }} + aria-valuemin={3} + aria-valuemax={50} + className={cn( + 'block w-24 rounded-lg border border-slate-300 px-3 py-1.5 text-sm', + 'text-slate-800 dark:text-slate-200', + 'bg-white dark:bg-slate-900', + 'dark:border-slate-700', + 'focus:outline-none focus:ring-2 focus:ring-crystal-500 focus:border-crystal-500', + )} + /> +
+
+
+ ); +} diff --git a/src/components/quiz/study/UnvettedFlag.test.tsx b/src/components/quiz/study/UnvettedFlag.test.tsx new file mode 100644 index 0000000..c8fb395 --- /dev/null +++ b/src/components/quiz/study/UnvettedFlag.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { UnvettedFlag } from './UnvettedFlag'; + +describe('UnvettedFlag', () => { + // ── unvetted=false ───────────────────────────────────────────────────────── + + it('renders nothing when unvetted=false', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + // ── unvetted=true — structural ───────────────────────────────────────────── + + it('renders when unvetted=true', () => { + render(); + // Badge text "Auto-generated" — use getAllBy since tooltip also matches + const matches = screen.getAllByText(/auto-generated/i); + expect(matches.length).toBeGreaterThan(0); + }); + + it('has an accessible label on the icon trigger', () => { + render(); + expect( + screen.getByRole('img', { name: /auto-generated question/i }), + ).toBeInTheDocument(); + }); + + it('has a tooltip element with role="tooltip"', () => { + render(); + expect(screen.getByRole('tooltip')).toBeInTheDocument(); + }); + + it('tooltip mentions expert-reviewed', () => { + render(); + expect(screen.getByRole('tooltip')).toHaveTextContent(/expert-reviewed/i); + }); + + // ── Tooltip visibility on hover ──────────────────────────────────────────── + + it('tooltip is initially hidden (opacity-0 / pointer-events-none)', () => { + render(); + const tooltip = screen.getByRole('tooltip'); + // We test via class since opacity is CSS-only and jsdom won't compute it + expect(tooltip).toHaveClass('opacity-0'); + }); + + it('tooltip becomes visible on mouse enter', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('img', { name: /auto-generated question/i }); + await user.hover(trigger); + expect(screen.getByRole('tooltip')).toHaveClass('opacity-100'); + }); + + it('tooltip hides again on mouse leave', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('img', { name: /auto-generated question/i }); + await user.hover(trigger); + await user.unhover(trigger); + expect(screen.getByRole('tooltip')).toHaveClass('opacity-0'); + }); + + // ── Focus-based tooltip ──────────────────────────────────────────────────── + + it('tooltip shows on focus', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('img', { name: /auto-generated question/i }); + await user.tab(); // focus the tabIndex=0 element + // Fallback: fireEvent.focus + trigger.focus(); + expect(screen.getByRole('tooltip')).toHaveClass('opacity-100'); + }); + + // ── aria-describedby linkage ─────────────────────────────────────────────── + + it('trigger is described by the tooltip', () => { + render(); + const trigger = screen.getByRole('img', { name: /auto-generated question/i }); + const tooltip = screen.getByRole('tooltip'); + expect(trigger).toHaveAttribute('aria-describedby', tooltip.id); + }); +}); diff --git a/src/components/quiz/study/UnvettedFlag.tsx b/src/components/quiz/study/UnvettedFlag.tsx new file mode 100644 index 0000000..7052ae0 --- /dev/null +++ b/src/components/quiz/study/UnvettedFlag.tsx @@ -0,0 +1,75 @@ +/** + * UnvettedFlag — warning indicator for auto-generated, unreviewed questions. + * + * Renders only when `unvetted` is true. Combines IconBox (warning icon) with a + * Badge label and an accessible tooltip via aria-describedby. + * + * Uses `IconBox` and `Badge` from `@/components/ui`. + */ + +import { useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { IconBox } from '../../ui/IconBox'; +import { Badge } from '../../ui/Badge'; +import { cn } from '../../ui/cn'; + +interface UnvettedFlagProps { + /** Whether this question is auto-generated and not yet expert-reviewed. */ + unvetted: boolean; +} + +const TOOLTIP_ID = 'unvetted-tooltip'; + +export function UnvettedFlag({ unvetted }: UnvettedFlagProps) { + const [tooltipVisible, setTooltipVisible] = useState(false); + + if (!unvetted) return null; + + return ( + + {/* Icon trigger */} + setTooltipVisible(true)} + onMouseLeave={() => setTooltipVisible(false)} + onFocus={() => setTooltipVisible(true)} + onBlur={() => setTooltipVisible(false)} + tabIndex={0} + > + + + + {/* Badge label */} + + Auto-generated + + + {/* Tooltip */} + + Auto-generated, not yet expert-reviewed. Treat this question with extra + scepticism and check the source article if unsure. + + + ); +} diff --git a/src/components/quiz/study/index.ts b/src/components/quiz/study/index.ts new file mode 100644 index 0000000..f999f5b --- /dev/null +++ b/src/components/quiz/study/index.ts @@ -0,0 +1,9 @@ +export { ConfidenceTap } from './ConfidenceTap'; +export { RationalePanel } from './RationalePanel'; +export type { OptionRationale } from './RationalePanel'; +export { ScheduleBadge } from './ScheduleBadge'; +export { UnvettedFlag } from './UnvettedFlag'; +export { LearnQuizWidget } from './LearnQuizWidget'; +export type { WidgetQuestion } from './LearnQuizWidget'; +export { StudySettingsPanel } from './StudySettingsPanel'; +export { ExportImportPanel } from './ExportImportPanel'; diff --git a/src/pages/_dev/study-components.astro b/src/pages/_dev/study-components.astro new file mode 100644 index 0000000..7ba2b26 --- /dev/null +++ b/src/pages/_dev/study-components.astro @@ -0,0 +1,35 @@ +--- +/** + * Dev harness for study system UI components. + * + * IMPORTANT: This page is only available in development. + * In a production build, `import.meta.env.DEV` is false and we return 404. + * + * Usage: npm run dev → http://localhost:4321/_dev/study-components + */ + +import '../../styles/global.css'; +import { StudyDevHarness } from '../../components/quiz/study/StudyDevHarness'; + +if (!import.meta.env.DEV) { + return new Response(null, { status: 404 }); +} +--- + + + + + + + Study Components — Dev Harness + + + + + + + + diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a798d4d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.tsx', 'src/**/*.test.ts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@components': path.resolve(__dirname, './src/components'), + '@lib': path.resolve(__dirname, './src/lib'), + '@styles': path.resolve(__dirname, './src/styles'), + }, + }, +}); From 98abcf59e64ca71afd88cf74dfbc8fd8a7e72993 Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:25:27 +0700 Subject: [PATCH 07/16] docs(study): add study-system overview, authoring guide, SM-2 ADR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/study-system.md — system overview with three Mermaid diagrams (data flow, SM-2 state machine, component tree) - docs/authoring-questions.md — curator-facing 5-step workflow guide - docs/adr/0001-sm2-vs-fsrs.md — architecture decision record for the SM-2 → FSRS upgrade path Refs V1-PLAN.md §1, §3, §5, §6, §10, §13. --- docs/adr/0001-sm2-vs-fsrs.md | 105 ++++++++++ docs/authoring-questions.md | 283 +++++++++++++++++++++++++++ docs/study-system.md | 368 +++++++++++++++++++++++++++++++++++ 3 files changed, 756 insertions(+) create mode 100644 docs/adr/0001-sm2-vs-fsrs.md create mode 100644 docs/authoring-questions.md create mode 100644 docs/study-system.md diff --git a/docs/adr/0001-sm2-vs-fsrs.md b/docs/adr/0001-sm2-vs-fsrs.md new file mode 100644 index 0000000..8eb43d7 --- /dev/null +++ b/docs/adr/0001-sm2-vs-fsrs.md @@ -0,0 +1,105 @@ +# ADR-0001: Adopt SM-2 for v1 Spaced Repetition, with FSRS Upgrade Path to v1.1 + +**Status:** Accepted +**Date:** 2026-05-05 +**Author:** documentation-architect (T6), based on V1-PLAN.md §2 (A4) and REPORT.md §4 (P1.2) + +--- + +## Context + +The study system v1 adds spaced-repetition scheduling to the gemmology.dev quiz system. Every answered question needs a scheduling algorithm that decides when it should next appear in a session. Two algorithms are candidates: + +**SM-2** (SuperMemo 2, 1987) is the algorithm used by Anki's classic scheduler. It maintains, per question, an ease factor (EF, default 2.5) and an interval in days. After each review, the interval is multiplied by the EF if the answer was successful (quality >= 3), or reset to 1 day on failure. EF adjusts up or down based on a quality scalar 0–5. + +**FSRS** (Free Spaced Repetition Scheduler, 2022) is a more recent algorithm developed by Jarrett Ye. It models memory using a continuous forgetting curve and learns per-user parameters from response history. It is increasingly adopted in Anki as an optional replacement for the classic SM-2 scheduler. + +The decision is bounded: either algorithm can be implemented as a pure module (`src/lib/quiz/scheduler.ts`) behind the `updateSchedule(entry, quality, now) → ScheduleEntry` signature. The `ScheduleEntry` type is shared regardless of which algorithm populates it. Swapping algorithms does not require component changes. + +### Constraints + +1. The system launches with zero historical response data. No per-user or per-item parameters can be learned at launch. +2. The engineering team is small. Debugging complexity of a scheduler bug costs real time. +3. The evidence base for spaced repetition in professional certification study is strong in aggregate but sparse for FSRS specifically in indexed, peer-reviewed literature. +4. The v1.0 acceptance timeline is 4–6 weeks (V1-PLAN.md §12). FSRS implementation is more complex than SM-2. + +--- + +## Decision + +**Adopt SM-2 for v1.0.** + +The `scheduler.ts` module implements the classic SM-2 algorithm exactly as described in V1-PLAN.md §5.1. The quality-to-confidence mapping (V1-PLAN.md §A4) translates the three-level confidence tap (unsure / fairly sure / certain) crossed with correctness into a 0–5 integer quality score that SM-2 expects. + +The `ScheduleEntry` type is designed to be algorithm-agnostic. The fields `intervalDays`, `easeFactor`, `repetitions`, `lapses`, `lastReviewed`, `nextDue`, and `totalReviews` are sufficient for SM-2 and are a superset of the data FSRS requires (FSRS uses stability and retrievability estimates rather than EF, but these can be derived from or stored alongside the existing fields in a migration). + +The FSRS upgrade is scoped to v1.1 and is a drop-in module replacement. + +--- + +## Consequences + +### Accepted trade-offs of SM-2 + +**Overconfident on lapses.** When a question lapses after a long interval (e.g., 30 days), SM-2 collapses the interval immediately to 1 day. There is no smooth decay — the algorithm treats a forgotten card as if it were brand new. FSRS models the residual memory trace more accurately. In practice, for a question bank at the scale of v1.0 (50–150 curated items), this distinction is unlikely to be perceptible to users. + +**Single quality scalar.** SM-2 receives a single integer 0–5. The richer signal available from the confidence tap (correct/incorrect × unsure/fairly-sure/certain × response time) is compressed into that integer. Information is discarded. FSRS can in principle accept a richer signal, though its public interface also uses a quality scalar in most implementations. + +**No parameter learning.** SM-2 uses a fixed formula with constants. The ease factor adapts per-item based on individual review history, but the constants (EF minimum, adjustment coefficients) are the same for every user. FSRS learns optimal per-user parameters from population data. At v1.0 launch, there is no population data, so this is not a practical disadvantage — it becomes one only after significant response volume accumulates (roughly N > 200 responses per item for meaningful FSRS parameter fitting). + +**Potential for interval stagnation on difficult items.** The EF minimum is clamped at 1.3. An item answered incorrectly repeatedly will have its EF driven toward 1.3 and its intervals will grow very slowly. This is correct behaviour — it keeps difficult items in frequent review — but SM-2 does not explicitly model the distinction between "genuinely difficult" and "badly written item." FSRS's stability model is somewhat better at this, but item analysis (flagging items with p-value < 0.20 and point-biserial r < 0.15) is the correct long-term fix and is planned for v1.1 (V1-PLAN.md §5.4). + +### Positive consequences + +SM-2 has been validated in medical education contexts (Kornell 2009, DOI: 10.1002/acp.1537; Mohamed 2025, PMID: 41798361). Its behaviour is well-understood, its edge cases (new card, lapsed card, first repetition, EF floor) are thoroughly documented, and the Anki community has decades of reported experience with its failure modes. This makes unit tests straightforward to write against known reference cases (V1-PLAN.md §9, `scheduler.test.ts`). + +The simplicity of the implementation reduces the risk of subtle scheduling bugs that would be difficult to diagnose from user reports. If the scheduler produces an unexpected interval, the `ScheduleEntry` stored in `localStorage` is fully human-readable and debuggable in browser DevTools. + +### FSRS upgrade path (v1.1) + +The upgrade requires: + +1. Replace `scheduler.ts` with an FSRS implementation (several TypeScript ports of the reference implementation exist). +2. Write a migration in `store/migrations.ts` that converts `ScheduleEntry.easeFactor` and `ScheduleEntry.intervalDays` into FSRS stability estimates. The conversion is lossy but acceptable for a one-time migration. +3. Update `ScheduleEntry` to include FSRS-specific fields (stability, difficulty, retrievability) alongside the existing SM-2 fields during the transition period. +4. No component changes required. + +The decision to migrate should be triggered by: (a) a published RCT comparing SM-2 and FSRS in professional certification study appearing in an indexed database, or (b) user feedback indicating that interval growth feels wrong for a significant fraction of items. + +--- + +## Alternatives Considered + +### Alternative 1 — Leitner box system + +A simpler algorithm using three to five physical "boxes" with fixed intervals (daily, every three days, weekly, monthly). Easier to explain to non-technical curators. Rejected because: the fixed intervals cannot adapt to individual item difficulty; the system cannot distinguish a lucky correct guess from secure recall; and the Leitner box adds no technical simplicity over SM-2 (both are O(1) per review update). + +### Alternative 2 — FSRS from day one + +Implement FSRS immediately. The argument is that it is strictly better than SM-2 and the switching cost grows over time as response data accumulates in SM-2 format. Rejected because: no indexed RCT comparing the two algorithms in a professional certification context has been located (REPORT.md §6, "FSRS vs SM-2 RCT: UNVERIFIED"); FSRS implementation and debugging is more complex; and the parameter-learning advantage of FSRS requires significant response volume (N > 200 per item) to materialise — volume that does not exist at v1.0 launch. + +### Alternative 3 — No scheduler; weighted random selection + +Simply weight question selection by the inverse of last-attempt accuracy (worse performance → higher selection probability). Easier to implement; no per-item state. Rejected because: it does not encode the time dimension (forgetting curve); a question answered perfectly two months ago is treated identically to one answered perfectly five minutes ago; and the evidence base for spaced repetition specifically (as opposed to any adaptive system) is strong enough to justify the additional complexity (spacing effect: 74% vs 57% retention, Kornell 2009). + +--- + +## Evidence Summary + +| Claim | Source | Confidence | +|-------|--------|------------| +| Spacing effect: 74% vs 57% retention | Kornell 2009 (DOI: 10.1002/acp.1537) | VERIFIED | +| SM-2 effective in medical education | Mohamed 2025 (PMID: 41798361) | PARTIALLY_SUPPORTED | +| No indexed RCT comparing FSRS vs SM-2 | Literature search, 2026-05-05 | VERIFIED (absence) | +| 3-option MCQ equivalent to 4-option | Sridharan 2025 (DOI: 10.1186/s12909-025-08026-5) | VERIFIED | + +Full citation registry: REPORT.md §6. + +--- + +## Related Documents + +- [docs/study-system.md](../study-system.md) — SM-2 mechanics, worked example, known weaknesses +- V1-PLAN.md §2 (A4) — architectural decision rationale (primary source) +- V1-PLAN.md §5.1 — `updateSchedule` implementation reference +- REPORT.md §4 (P1.2) — pedagogical evidence for spaced repetition diff --git a/docs/authoring-questions.md b/docs/authoring-questions.md new file mode 100644 index 0000000..f8d3a7b --- /dev/null +++ b/docs/authoring-questions.md @@ -0,0 +1,283 @@ +# Authoring Questions — Curator's Guide + +> **Diataxis type:** How-to — goal-oriented guide for contributors authoring and reviewing curated questions. +> +> **Audience:** FGA-trained content reviewers and internal contributors writing questions for the `src/content/questions/` sidecar bank. This guide assumes familiarity with gemmological terminology and basic YAML editing; it does not assume programming experience. + +--- + +## Who This Guide Is For + +This guide is for the **curator** role described in V1-PLAN.md §10: the person who writes and reviews the question YAML files that form the expert-vetted item bank. You do not need to know TypeScript or Astro internals. You do need: + +- FGA Foundation or equivalent qualification (or close supervision by someone who has one) +- A text editor with YAML support (VS Code with the YAML extension is recommended — the JSON Schema auto-complete works immediately after running `npm run schema:questions`) +- Git access to the repository (a GitHub account with contributor access) + +The engineering infrastructure — the Zod schema, the validator, the CI gate — is maintained by the implementer track. If the validator rejects a file you believe is correct, raise the issue rather than working around it. + +--- + +## The Five-Step Authoring Workflow + +### Step 1 — Generate a stub file + +Run the scaffold script from the repository root: + +```bash +npm run question:new -- --category=species --slug=corundum-ri-002 +``` + +This creates `src/content/questions/species/corundum-ri-002.yaml` pre-populated with every required field as a placeholder. The `--category` argument must be one of the eight categories (`fundamentals`, `equipment`, `species`, `identification`, `phenomena`, `origin`, `market`, `care`). The `--slug` argument becomes the file name and the question's unique `id`; use lowercase letters, digits, and hyphens only. + +The generated stub looks like this: + +```yaml +id: corundum-ri-002 +stem: "TODO: write a scenario stem" +type: mcq +options: + - text: "TODO: correct answer" + isCorrect: true + rationale: "TODO: why this is correct" + - text: "TODO: plausible distractor" + isCorrect: false + rationale: "TODO: why this is wrong" + - text: "TODO: plausible distractor" + isCorrect: false + rationale: "TODO: why this is wrong" +rationaleCorrect: "TODO: diagnostic principle" +difficulty: 3 +category: species +conceptTags: [] +unvetted: true +``` + +### Step 2 — Write the stem, options, and rationales + +Replace every `TODO:` field. The example below is taken directly from V1-PLAN.md §4.2 and illustrates the expected quality level: + +```yaml +id: corundum-ri-001 +stem: | + A polished red stone gives the following readings on standard lab instruments: + RI 1.762–1.770, SG 4.00, uniaxial negative on the polariscope, strong red fluorescence + under LWUV. What is the most likely identification? +type: mcq +options: + - text: "Natural ruby (corundum)" + isCorrect: true + rationale: "All four readings are diagnostic of corundum: the RI range, SG 4.00, uniaxial + negative optic character, and strong red LWUV fluorescence are textbook ruby." + - text: "Spinel" + isCorrect: false + rationale: "Spinel is isotropic (single RI ≈ 1.718) and would not give a uniaxial reading. + Burma spinel does fluoresce red under LWUV but the RI and optic character rule it out." + - text: "Pyrope garnet" + isCorrect: false + rationale: "Pyrope is also isotropic with RI ≈ 1.730–1.760 and SG 3.7–3.9. Slight RI overlap + but the uniaxial reading and SG 4.00 exclude it." +rationaleCorrect: | + This is a near-miss case the FGA practical exam tests: three red gems with overlapping properties. + The diagnostic chain is optic character first (eliminates spinel and pyrope as isotropic), + then SG (eliminates remaining alternatives at 4.00). +difficulty: 3 +``` + +Note that the stem is written in scenario form — a real instrument reading, a concrete situation — rather than a bare knowledge retrieval question ("What is the RI of corundum?"). Scenario stems are harder to write but generate significantly stronger learning outcomes; the bare-retrieval form is what the auto-generator produces and is explicitly flagged as unvetted. + +### Step 3 — Add metadata fields + +After the core question content, complete the metadata block: + +```yaml +category: species +conceptTags: [refractive-index, specific-gravity, optic-character, fluorescence] +sourceArticle: species/corundum +examRelevance: FGA-diploma +confusionPairs: [spinel-001, pyrope-001] +similarTo: [corundum-ri-002, ruby-fluorescence-001] +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-15T10:00:00Z" +unvetted: false +``` + +Field-by-field guidance: + +- **`conceptTags`** — one to five lowercase, hyphenated tags matching the gemmological property tested. Use existing tags already in the bank where possible; new tags are fine but keep them stable. +- **`sourceArticle`** — the learn slug of the article that teaches this topic (e.g. `species/corundum` for `/learn/species/corundum`). The `LearnQuizWidget` uses this to surface pretests on the correct article. +- **`examRelevance`** — one of `FGA-foundation`, `FGA-diploma`, or `GIA-GG`. If in doubt, use `FGA-foundation`. +- **`confusionPairs`** — the `id` values of other questions that test the most common near-miss confusions. This is the metadata that powers near-miss interleaving: when a session includes `corundum-ri-001`, the interleaver ensures a spinel or pyrope question appears within the next five positions. Populating this field is high-value work. +- **`similarTo`** — broader grouping than `confusionPairs`; questions on the same topic or instrument reading pattern. Used as a secondary interleaving signal. +- **`authorReviewed`** — your reviewer handle or initials. Required before `unvetted` can be set to `false`. +- **`lastReviewed`** — ISO 8601 datetime of your review. Update this whenever you revise the question. +- **`unvetted: false`** — set this only when you are satisfied the item meets the quality bar below. Auto-generated items always carry `unvetted: true`. + +### Step 4 — Validate locally + +Before committing, run: + +```bash +npm run validate:questions +``` + +The validator runs the Zod schema against every file in `src/content/questions/`. It exits with code 0 if all files are valid; it prints the failing file path and the specific schema violation otherwise. Common failures are documented in the "Common review failures" section below. + +You can also check the current coverage of your category: + +```bash +npm run questions:coverage +``` + +This prints a table of curated item counts per category, vetted versus unvetted, and highlights categories below the minimum threshold. + +### Step 5 — Open a pull request + +Push your branch and open a PR against `feature/study-v1` (or `main` after the v1 merge). CI will: + +1. Run `npm run validate:questions` — any schema violation blocks the merge. +2. Run `npm run build` — confirms no Astro type errors from the new content collection entry. +3. (After v1.0) Run the unit test suite. + +At least one other FGA-qualified reviewer should approve the rationale content before merge. Engineering review is not required unless you are modifying the schema or scripts. + +--- + +## Quality Bar for Curated Items + +> This section restates V1-PLAN.md §10 with additional worked examples. + +A question passes review when it satisfies all of the following criteria. + +### 1. Scenario stem + +The stem describes a realistic situation — an instrument reading, an observation under the loupe, a client description — not a bare knowledge retrieval. Compare: + +| Reject (auto-gen quality) | Accept (curated quality) | +|--------------------------|--------------------------| +| "What is the refractive index of corundum?" | "A red stone gives RI 1.762–1.770 on the refractometer, uniaxial negative on the polariscope. What is the most likely identification?" | +| "Which crystal system does tourmaline belong to?" | "A prismatic green stone shows strong pleochroism, a uniaxial interference figure, and RI 1.620–1.640. Which crystal system does it belong to?" | + +### 2. Three options, one correct + +Use three options (one correct, two distractors). The schema allows up to four options for cases where "all of the above" is genuinely useful, but three is the default. Psychometric research (Sridharan & Sivaramakrishnan 2025, DOI: 10.1186/s12909-025-08026-5) shows three-option items are equivalent to four-option items and halve the distractor-writing burden. + +### 3. Plausible distractors — not absurd ones + +Each distractor must represent an actual mistake an FGA Foundation student is likely to make. The gemmological confusion matrix is well-defined: + +- Ruby, spinel, pyrope garnet (red stone near-misses) +- Emerald, chrome tourmaline, green glass, peridot (green stone near-misses) +- Alexandrite, colour-change sapphire (colour-change near-misses) +- Citrine, heat-treated amethyst (yellow/orange near-misses) + +Distractors that are physically impossible or geologically absurd fail this criterion. + +### 4. Per-distractor rationale + +Every distractor needs a `rationale` field that explains precisely why that distractor is wrong in the context of this question. Simply restating the correct answer is not sufficient. The rationale should teach the diagnostic principle — which property, measurement, or observation rules the distractor out. + +The correct-answer `rationale` (inside the `options` array) and the `rationaleCorrect` field serve different purposes: + +- **Option-level `rationale`** — why this specific option is correct or wrong. Short; one to two sentences. +- **`rationaleCorrect`** — the broader diagnostic principle illustrated by the question. Reference the `/learn/[slug]` article section where the student can read more. This field is displayed as the teaching moment after submission in practice mode. + +### 5. Difficulty anchored to the 1–5 scale + +Use the following anchors. When in doubt, pilot the question on a student and adjust: + +| Level | Description | Example | +|-------|-------------|---------| +| 1 | Direct recall, single property | "Corundum belongs to which crystal system?" | +| 2 | Two-step lookup, obvious distractors | "RI 1.544 and SG 2.65 is most consistent with which gem?" | +| 3 | Near-miss discrimination, two properties | "RI 1.762, uniaxial negative: ruby, spinel, or pyrope?" | +| 4 | Multi-property chain with subtle overlap | "RI 1.718, isotropic, red fluorescence: spinel or synthetic spinel?" | +| 5 | Full identification scenario with treatment detection | Complete instrument suite + inclusion description | + +### 6. Citation format + +Where a rationale makes a specific factual claim — a numerical property value, a diagnostic rule, a treatment detection method — cite the source using the inline marker format: + +```yaml +rationaleCorrect: | + The diagnostic chain for corundum is optic character first (uniaxial negative eliminates + all isotropic species), then SG at 4.00. [ref:read-gemmology-3rd-p187] +references: + - id: read-gemmology-3rd-p187 + text: "Read, P.G. (2008). Gemmology, 3rd ed. Elsevier. p. 187." +``` + +The `[ref:id]` marker is an inline anchor; the `references:` block lists the full citation. The T5b Citations track (biss-research:citation-verifier) verifies factual claims in question rationales and adds `# TODO: verify` comments on anything unverifiable. + +--- + +## Voice and Tone + +The study system addresses FGA Foundation and Diploma candidates who are serious students, not beginners being hand-held. The register is: + +- **Clear and precise.** Use the correct gemmological term every time — do not simplify to colloquial equivalents. +- **Encouraging without being patronising.** Rationales should teach, not scold. "Spinel is singly refractive, which rules it out here" not "You forgot that spinel is isotropic!" +- **Diagnostic, not encyclopaedic.** The rationale is not a summary of everything known about the species. It answers one question: what distinguishes the correct answer from the distractor in this specific case? +- **Consistent with `/learn/` article voice.** The rationale for a question sourced from `species/corundum` should feel like a short extension of that article, not a different document. + +No emojis. No rhetorical questions in stems ("Could this be ruby?"). No hedging in rationales ("It might be that..."). + +--- + +## Common Review Failures + +The validator catches schema violations. The following failures pass the schema but fail the quality bar on human review. + +### Failure 1 — Absurd distractor + +```yaml +# Bad: no candidate would choose "obsidian" for a uniaxial negative red stone +- text: "Obsidian (volcanic glass)" + isCorrect: false + rationale: "Obsidian is amorphous and shows no birefringence." +``` + +The distractor must be something a student might plausibly confuse for the correct answer. Obsidian is not in the ruby confusion matrix. + +### Failure 2 — Rationale restates the answer + +```yaml +# Bad: does not explain the diagnostic reasoning +- text: "Spinel" + isCorrect: false + rationale: "Spinel is not the correct answer here." +``` + +The rationale must identify which property rules spinel out and why that property matters. + +### Failure 3 — Bare-retrieval stem + +```yaml +# Bad: this is auto-gen quality, not curated quality +stem: "What is the SG of diamond?" +``` + +Rewrite as a scenario: "A colourless stone tests adamantine lustre, inert under UV, and balances at SG 3.52. What is the most likely identity?" + +### Failure 4 — `confusionPairs` left empty on a near-miss question + +```yaml +# If the distractors are spinel-001 and pyrope-001, those ids go here +confusionPairs: [] +``` + +When the question explicitly tests a known confusion pair, the `confusionPairs` field must be populated with the ids of the distractor questions. This is what powers near-miss interleaving and is the highest-value metadata in the bank. + +### Failure 5 — `unvetted: false` without `authorReviewed` + +The schema requires `authorReviewed` to be present when `unvetted` is `false`. The validator will catch this, but the underlying issue is that no item should leave draft state without a named reviewer taking responsibility for the content. + +--- + +## Cross-references + +- Question schema (Zod): `src/content/config.ts` (T4 track); `src/content/questions/_example/` for the template +- SM-2 quality mapping (how confidence affects scheduling): [docs/study-system.md](study-system.md#scheduling-algorithm) +- Interleaving mechanics (`confusionPairs` → `interleaver.ts`): [docs/study-system.md](study-system.md#components-and-routes) +- ADR for SM-2 vs FSRS: [docs/adr/0001-sm2-vs-fsrs.md](adr/0001-sm2-vs-fsrs.md) +- Coverage targets (quarterly milestones): V1-PLAN.md §10 diff --git a/docs/study-system.md b/docs/study-system.md new file mode 100644 index 0000000..80fa4a0 --- /dev/null +++ b/docs/study-system.md @@ -0,0 +1,368 @@ +# Study System — v1 Overview + +> **Diataxis type:** Explanation — understanding-oriented discussion of the v1 study system architecture, data model, and scheduling algorithm. +> +> **Audience:** [Developer] [Architecture] — future maintainers, contributors integrating new question types, and engineers extending the scheduler. + +--- + +## Purpose + +The v1 study system replaces the prototype quiz widget with a research-grounded learning tool. The prototype (described in `REPORT.md §1`) generated questions from YAML learn content on each session start, but treated every attempt as a first attempt — there was no per-question history, no scheduling, and no rationale feedback. The result was a functional self-assessment tool that offered no lasting pedagogical value: users could repeat the same questions indefinitely without the system ever adapting. + +Version 1 adds four structural capabilities the prototype lacked. First, every question response is recorded, creating a persistent history ledger that all other features depend on. Second, SM-2 spaced-repetition scheduling prioritises overdue and fragile items in every session, actively fighting the forgetting curve. Third, per-distractor rationales (correct answer and each wrong option) are surfaced after submission, which the evidence base rates as the single highest-leverage pedagogical addition above the baseline testing effect (Butler, Karpicke & Roediger 2007; DOI: 10.1037/1076-898x.13.4.273). Fourth, a confidence tap before answer reveal gives the scheduler a quality signal that distinguishes lucky correct answers from secure recall, and provides the basis for future metacognitive calibration features. + +The system continues to support all prototype features — categories, difficulty filter, exam mode, navigation grid, pass/fail threshold — without regression. + +See [V1-PLAN.md §1](/Users/fabian/Downloads/gemmology-study-system-review-2026-05-05/V1-PLAN.md) for the full goals and non-goals. + +--- + +## Data Model + +> **Diataxis type:** Reference + +The study system uses five `localStorage` keys. Three are new in v1; two (`gemmology-quiz-state` and `gemmology-exam-state`) carry over from the prototype with a `confidence` field added. + +### Four primary storage keys (v1 additions) + +| Key | Purpose | Capped at | +|-----|---------|-----------| +| `gemmology-study-responses` | Append-only response history | 10,000 records | +| `gemmology-study-schedule` | SM-2 scheduling entry per question | Unbounded (one entry per question) | +| `gemmology-study-progress` | Wires up the existing `UserProgress` shape | Single object | +| `gemmology-study-settings` | User preferences (rationale display, confidence required, review mix) | Single object | + +A fifth key, `gemmology-study-version` (integer), drives the migration runner. + +#### `gemmology-study-responses` — ResponseStore + +```ts +// V1-PLAN.md §3.1 +type ResponseRecord = { + questionId: string; // matches Question.id + timestamp: number; // unix ms + correct: boolean; + confidence: 'unsure' | 'fairly-sure' | 'certain'; + timeMs: number; // time spent on the question + mode: 'practice' | 'exam' | 'pretest'; + optionChosen?: string; // text or id of selected option + sessionId: string; // groups responses from one quiz session +}; + +type ResponseStore = { + version: 1; + responses: ResponseRecord[]; // append-only; capped at 10,000 most recent +}; +``` + +The 10,000-record cap (approximately 1.5 MB JSON) keeps the store within the 5 MB `localStorage` limit. Records are trimmed from the head when the cap is exceeded. The `sessionId` field allows per-session aggregate analysis without a server round-trip. + +The `confidence` type will ultimately be defined in `src/lib/quiz/study-types.ts` (the contracts file from the T1 Foundation track). The `Confidence` union `'unsure' | 'fairly-sure' | 'certain'` maps to SM-2 quality values 2–3 / 4 / 5 for correct answers and 2 / 1 / 0 for incorrect answers respectively (see V1-PLAN.md §A4). + +#### `gemmology-study-schedule` — ScheduleStore + +```ts +// V1-PLAN.md §3.2 +type ScheduleEntry = { + questionId: string; + nextDue: number; // unix ms; <=0 means "due now" + intervalDays: number; // SM-2 I_n + easeFactor: number; // SM-2 EF, starts at 2.5 + repetitions: number; // consecutive successful recalls (SM-2 n) + lapses: number; // total times answered incorrectly + lastReviewed: number; // unix ms + totalReviews: number; +}; + +type ScheduleStore = { + version: 1; + entries: Record; // keyed by questionId +}; +``` + +#### `gemmology-study-settings` — StudySettings + +```ts +// V1-PLAN.md §3.5 +type StudySettings = { + version: 1; + showRationaleOnSubmit: boolean; // default true + requireConfidence: boolean; // default true + preferredQuestionCount: number; // last used count + reviewMixRatio: number; // 0–1, fraction due vs new, default 0.7 +}; +``` + +#### `gemmology-study-progress` — UserProgress + +Re-uses the existing `UserProgress` shape from `src/lib/quiz/progress-tracker.ts` (`question-types.ts:164`). The v1 change is that `progressTracker.recordSession` is finally wired into `useQuiz` and `useExam` — it was implemented but never called in the prototype (REPORT.md §2, "One latent bug"). + +### StorageStore interface + +All access goes through a `StudyStore` interface (defined in `src/lib/quiz/store/index.ts`, authored by the T1 Foundation track). The concrete `LocalStudyStore` in `src/lib/quiz/store/local.ts` implements it against `localStorage`. A future `RemoteStudyStore` can slot in without touching any component. See V1-PLAN.md §3.6 for the full interface definition. + +--- + +## Scheduling Algorithm + +> **Diataxis type:** Explanation + +Version 1 uses **SM-2** (SuperMemo 2), the algorithm behind Anki's classic scheduler. SM-2 operates on each question independently, updating an ease factor and an interval in days after every review. + +### Why SM-2 over FSRS + +FSRS (Free Spaced Repetition Scheduler) is a more recent algorithm with improved parameter learning, but at the time of the v1 decision no indexed randomised controlled trial had directly compared it to SM-2 in an educational setting (REPORT.md §6, "FSRS vs SM-2 RCT: UNVERIFIED"). SM-2 has a larger published evidence base in medical and professional education (Kornell 2009, DOI: 10.1002/acp.1537; Mohamed 2025, PMID: 41798361), is simpler to unit-test against known reference cases, and represents a well-understood upgrade path for any future migration to FSRS. The full decision is documented in [docs/adr/0001-sm2-vs-fsrs.md](adr/0001-sm2-vs-fsrs.md). + +### SM-2 mechanics + +The algorithm is implemented in `src/lib/quiz/scheduler.ts` (T2 Algorithms track). Its public surface is a single pure function: + +``` +updateSchedule(entry, quality, now) → ScheduleEntry +``` + +Where `quality` is an integer 0–5 derived from the confidence tap × correctness matrix (V1-PLAN.md §A4): + +| Correctness | Confidence | SM-2 quality | +|-------------|------------|-------------| +| Correct | Certain | 5 | +| Correct | Fairly sure | 4 | +| Correct | Unsure | 3 | +| Incorrect | Unsure | 2 | +| Incorrect | Fairly sure | 1 | +| Incorrect | Certain | 0 | + +When `quality < 3` (any incorrect answer) the algorithm treats the response as a **lapse**: the repetition counter resets to zero and the interval collapses to 1 day. When `quality >= 3` (any correct answer) the interval grows: first recall schedules the next review in 1 day, second recall in 6 days, and every subsequent recall multiplies the previous interval by the ease factor (EF). + +The ease factor adjusts after every review according to the standard SM-2 formula, with a minimum clamp of 1.3 to prevent intervals from stagnating indefinitely on difficult items. + +### Worked example + +A new question (no history) is answered correctly with "certain" confidence (quality = 5): + +| Review | quality | repetitions | intervalDays | easeFactor | nextDue | +|--------|---------|-------------|-------------|------------|---------| +| 1st | 5 | 1 | 1 | 2.60 | +1 day | +| 2nd | 5 | 2 | 6 | 2.70 | +6 days | +| 3rd | 5 | 3 | 16 | 2.80 | +16 days | +| 3rd (lapse) | 0 | 0 | 1 | 1.70 | +1 day (reset) | + +After a lapse at the third review, the question re-enters the short-interval queue. The lapse counter increments regardless of the subsequent interval length, giving the item-analysis script (v1.1) a signal to flag persistently-lapsed items for editorial review. + +### Known weaknesses of SM-2 + +- **Overconfident on lapses.** A question lapsed after a long interval collapses immediately to a 1-day interval rather than a smoothly decaying curve. FSRS models this more gracefully. +- **Single quality scalar.** SM-2 collapses the multi-dimensional correctness/confidence/speed signal into a single integer. Information is lost. +- **No parameter learning.** EF starts at 2.5 for every user and every item; SM-2 does not learn per-user or per-item optimal parameters from population data. FSRS does. + +These weaknesses are acceptable for v1.0 given the small response volume at launch. The upgrade path to FSRS is bounded: replace `scheduler.ts` with an FSRS implementation, re-use the same `ScheduleEntry` shape, and no component changes are needed. + +--- + +## Components and Routes + +> **Diataxis type:** Reference + +### New components (v1) + +All new components live under `src/components/quiz/study/` to avoid mixing them with prototype components during the parallel build. See V1-PLAN.md §6.1 for the full component list. + +| Component | Purpose | +|-----------|---------| +| `ConfidenceTap` | Three-button row (Unsure / Fairly sure / Certain) displayed between option selection and submit | +| `RationalePanel` | Expandable panel showing `rationaleCorrect` and per-distractor rationales after submission | +| `ScheduleBadge` | Tiny indicator on each question card: "New", "Due", or "Mastered (in N days)" | +| `UnvettedFlag` | Visual warning on auto-generated questions not yet expert-reviewed | +| `LearnQuizWidget` | Three-question pretest widget embedded above each `/learn/[slug]` article | +| `StudySettingsPanel` | User toggles for confidence required, rationale auto-show, review mix ratio | +| `ExportImportPanel` | JSON export/import of all four localStorage keys | + +### Modified components + +| Component | Change summary | +|-----------|---------------| +| `QuizSetup.tsx` | Shows "X items due now / Y new" before mode selection; adds "Review Now" shortcut | +| `Quiz.tsx` | Inserts `` between selection and submit; shows `` after submit; calls `scheduler.update` | +| `QuestionCard.tsx` | Accepts `imageRef`, shows `` and `` | +| `Exam.tsx` | Captures confidence per question; runs scheduler on batch submit | +| `ExamResults.tsx` | Per-question review now includes `` | +| `useQuiz.ts` | Wires `studyStore.appendResponse`, `studyStore.updateSchedule`, `progressTracker.recordSession` | +| `useExam.ts` | Same as `useQuiz.ts` | + +### New routes + +| Route | Purpose | +|-------|---------| +| `/study/review` | "Review now" express route — shows all due items with no setup screen | +| `/study/settings` | Settings panel plus export/import of localStorage state | +| `/_dev/study-components` | Dev-only harness for component isolation (gated by `import.meta.env.DEV`) | + +### New library modules + +``` +src/lib/quiz/ +├── study-types.ts # V1 type contracts: ResponseRecord, ScheduleEntry, Confidence, StudySettings +├── scheduler.ts # SM-2 pure functions +├── selector.ts # Due-aware selectQuestions (replaces prototype filter→shuffle→slice) +├── interleaver.ts # Near-miss interleaving (similarTo within 5 positions) +└── store/ + ├── index.ts # StudyStore interface + ├── local.ts # LocalStudyStore (localStorage implementation) + └── migrations.ts # v0→v1 migration runner +``` + +The existing modules (`question-types.ts`, `question-generator.ts`, `scoring.ts`, `progress-tracker.ts`, `shuffle.ts`) are unchanged or modified only by adding new exported types; their existing exports remain stable. + +--- + +## Question Authoring Lifecycle + +> **Diataxis type:** How-to + +The question bank operates as a hybrid: curated questions from `src/content/questions/` are loaded first; auto-generated questions from learn YAMLs fill gaps where curated coverage is below the minimum threshold. All auto-generated questions carry `unvetted: true` and are visually flagged in the UI. + +The curation pipeline has five stages: + +1. **Generate scaffold** — `npm run question:new -- --category=species --slug=corundum-ri-002` creates a stub YAML under `src/content/questions/species/`. +2. **Author** — fill in stem (scenario form), options (3 options, one correct), per-distractor rationale, `rationaleCorrect`, `conceptTags`, `sourceArticle`, and `confusionPairs`. +3. **Validate locally** — `npm run validate:questions` runs the Zod schema over every file in the collection. Exits non-zero on any violation. +4. **Open PR** — CI re-runs the validator. Any PR touching `src/content/questions/**` is blocked if validation fails. +5. **Merge and deploy** — the next build includes the question in the curated bank. + +Full guidance for curators is in [docs/authoring-questions.md](authoring-questions.md). + +--- + +## Diagram D1 — Data Flow + +The diagram below traces the path of a single study interaction from question selection through to the next session's question ordering. + +```mermaid +flowchart LR + subgraph Sources["Question Sources"] + CuratedBank["Curated bank\nsrc/content/questions/"] + AutoGen["Auto-generated\nfrom learn YAMLs"] + end + + Selector["selector.ts\nselectQuestions()"] + QuestionCard["QuestionCard\n+ ScheduleBadge"] + ConfidenceTap["ConfidenceTap\nunsure / fairly-sure / certain"] + Submit["Submit"] + RationalePanel["RationalePanel\ncorrect + distractor rationales"] + AppendResponse["studyStore\n.appendResponse()"] + UpdateSchedule["studyStore\n.updateSchedule()"] + ScheduleStore["gemmology-study-schedule\n(localStorage)"] + ResponseStore["gemmology-study-responses\n(localStorage)"] + NextSelector["Next session:\nselector.ts\ndue items ranked first"] + + CuratedBank --> Selector + AutoGen --> Selector + Selector --> QuestionCard + QuestionCard --> ConfidenceTap + ConfidenceTap --> Submit + Submit --> RationalePanel + Submit --> AppendResponse + Submit --> UpdateSchedule + AppendResponse --> ResponseStore + UpdateSchedule --> ScheduleStore + ResponseStore --> NextSelector + ScheduleStore --> NextSelector +``` + +_D1: A response to any question updates two stores — the append-only response history and the SM-2 schedule entry — which are both read by the selector on the next session start._ + +Evidence: V1-PLAN.md §3, §5.1, §5.2; `src/lib/quiz/question-types.ts:104–120` (QuizState), `src/lib/quiz/question-generator.ts:1–50`. + +--- + +## Diagram D2 — SM-2 State Machine + +The diagram below shows how a single review event transitions a question's scheduling state. The branch on quality < 3 (lapse) versus quality >= 3 (recall) drives all subsequent interval calculations. + +```mermaid +stateDiagram-v2 + [*] --> New : question first seen + New --> Reviewing : first response recorded + + state Reviewing { + [*] --> EvaluateQuality + EvaluateQuality --> Lapse : quality 0 – 2\n(any incorrect answer) + EvaluateQuality --> ShortRecall : quality 3\n(correct, unsure) + EvaluateQuality --> StrongRecall : quality 4 – 5\n(correct, sure / certain) + + Lapse --> ResetSchedule : repetitions = 0\nintervalDays = 1\nlapses += 1\nEF adjusted down + ShortRecall --> GrowInterval : repetitions += 1\nintervalDays × EF\nEF adjusted slightly + StrongRecall --> GrowInterval : repetitions += 1\nintervalDays × EF\nEF adjusted up + + ResetSchedule --> [*] : nextDue = now + 1 day + GrowInterval --> [*] : nextDue = now + intervalDays + } + + Reviewing --> Mastered : intervalDays > 21\nand no recent lapse + Mastered --> Reviewing : lapse detected\n(any quality 0 – 2) +``` + +_D2: Every review event is either a lapse (any incorrect answer, quality 0–2) or a recall (any correct answer, quality 3–5). Lapses reset the interval to one day regardless of prior mastery. A question is considered "Mastered" for display purposes when its scheduled interval exceeds 21 days without a recent lapse — this is a UI convention, not a hard SM-2 state._ + +Evidence: V1-PLAN.md §5.1 (`updateSchedule` function), §A4 (quality mapping table); `src/lib/quiz/question-types.ts:165–178` (UserProgress). + +--- + +## Diagram D3 — Component Tree + +The diagram below shows the parent–child rendering relationships for the two primary entry points: the `/quiz` route and a `/learn/[slug]` article page. Both share the `QuestionCard` subtree. + +```mermaid +flowchart TB + subgraph QuizRoute["/quiz route"] + ProtectedQuiz["ProtectedQuiz\n(auth boundary)"] + LockGate["LockGate\n(shared access code)"] + QuizSetup["QuizSetup\n(mode, categories, count)"] + QuizOrExam["Quiz | Exam\n(practice or exam mode)"] + end + + subgraph LearnRoute["/learn/[slug] route"] + LearnPage["learn/[...slug].astro"] + LearnQuizWidget["LearnQuizWidget\n(3-question pretest)"] + ArticleBody["Article body\n(SectionRenderer)"] + end + + subgraph QuestionCardTree["QuestionCard subtree (shared)"] + QC["QuestionCard"] + ScheduleBadge["ScheduleBadge\n(New / Due / Mastered)"] + UnvettedFlag["UnvettedFlag\n(auto-generated warning)"] + ConfidenceTap["ConfidenceTap\n(unsure / fairly-sure / certain)"] + RationalePanel["RationalePanel\n(post-submit rationales)"] + end + + ProtectedQuiz --> LockGate + LockGate --> QuizSetup + QuizSetup --> QuizOrExam + QuizOrExam --> QC + + LearnPage --> LearnQuizWidget + LearnPage --> ArticleBody + LearnQuizWidget --> QC + + QC --> ScheduleBadge + QC --> UnvettedFlag + QC --> ConfidenceTap + QC --> RationalePanel +``` + +_D3: Both `/quiz` and `/learn/[slug]` route to the shared QuestionCard subtree. The LearnQuizWidget is a lightweight wrapper that limits the session to three questions and tags responses with `mode: 'pretest'`. The ProtectedQuiz / LockGate boundary is unchanged from the prototype._ + +Evidence: V1-PLAN.md §6.1, §6.2; REPORT.md §1 (existing route architecture); `src/lib/quiz/question-types.ts:78–93` (QuizConfig). + +--- + +## Cross-references + +- Data model types: V1-PLAN.md §3; contracts will be in `src/lib/quiz/study-types.ts` +- SM-2 algorithm: V1-PLAN.md §5.1; `src/lib/quiz/scheduler.ts` (T2 track) +- Question bank schema: V1-PLAN.md §4.1; `src/content/config.ts` (T4 track) +- ADR for algorithm choice: [docs/adr/0001-sm2-vs-fsrs.md](adr/0001-sm2-vs-fsrs.md) +- Authoring guide: [docs/authoring-questions.md](authoring-questions.md) +- Prototype review: REPORT.md (pedagogical evidence base) +- Parent project development guide: [CLAUDE.md](/Users/fabian/gemmology/migration/CLAUDE.md) — Quiz System section From 87d689a41931085938e534fa98bc1e9646193393 Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:27:33 +0700 Subject: [PATCH 08/16] =?UTF-8?q?chore(study):=20integration=20fixes=20?= =?UTF-8?q?=E2=80=94=20unify=20vitest=20env=20to=20jsdom,=20refresh=20lock?= =?UTF-8?q?file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- vitest.config.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index f774aa0..ecfad17 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,16 +4,12 @@ import { resolve } from 'path'; export default defineConfig({ test: { - // Default to node; React component tests opt in to jsdom via a header - // comment (`// @vitest-environment jsdom`) at the top of each *.test.tsx. - environment: 'node', + // jsdom is used for everything — node-only tests still work under jsdom, + // and React component tests need it. Slightly slower but uniform. + environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], - environmentMatchGlobs: [ - ['src/components/**/*.test.tsx', 'jsdom'], - ['src/components/**/*.test.ts', 'jsdom'], - ], coverage: { provider: 'v8', reporter: ['text', 'json-summary'], From 132943f0843422006409cb959f47766f435f2a51 Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:31:05 +0700 Subject: [PATCH 09/16] chore(forge): add CODEOWNERS, study-track PR template, and v1 release checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .github/CODEOWNERS: auto-request @Bissbert on PRs touching study-v1 paths (study-types, scheduler, selector, interleaver, store/, quiz components, questions collection, content config, scripts, and docs) - .github/PULL_REQUEST_TEMPLATE/study-track.md: opt-in template (via ?template=study-track.md) with per-track checkboxes and the V1-PLAN §13.8 acceptance checklist - docs/release-checklist-study-v1.md: ordered pre-flight steps for the feature/study-v1 → main release PR, covering integration green, §12 acceptance criteria, bundle-size gate (<=+25 KB gz), T7 audit sign-off, tagging, IndexNow verification, Cloudflare live check, and worktree cleanup --- .github/CODEOWNERS | 37 ++++++ .github/PULL_REQUEST_TEMPLATE/study-track.md | 24 ++++ docs/release-checklist-study-v1.md | 127 +++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/PULL_REQUEST_TEMPLATE/study-track.md create mode 100644 docs/release-checklist-study-v1.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..e6e11ee --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,37 @@ +# CODEOWNERS — study v1 file ownership lattice +# +# Patterns follow GitHub CODEOWNERS syntax: last matching rule wins. +# All paths are relative to the repository root. +# @Bissbert is the sole maintainer; every path below auto-requests a review +# from them on any PR that touches the listed files or directories. + +# Study v1 — core type contracts +/src/lib/quiz/study-types.ts @Bissbert + +# Study v1 — scheduling algorithm +/src/lib/quiz/scheduler.ts @Bissbert + +# Study v1 — question selection +/src/lib/quiz/selector.ts @Bissbert + +# Study v1 — near-miss interleaving +/src/lib/quiz/interleaver.ts @Bissbert + +# Study v1 — store implementations and migrations +/src/lib/quiz/store/ @Bissbert + +# Quiz UI components (all tracks write here; single reviewer) +/src/components/quiz/ @Bissbert + +# Curated question bank (T5a/T5b write here) +/src/content/questions/ @Bissbert + +# Astro content collection schema (T4 sole owner) +/src/content/config.ts @Bissbert + +# Build and authoring scripts +/scripts/ @Bissbert + +# Documentation and ADRs +/docs/ @Bissbert +/docs/adr/ @Bissbert diff --git a/.github/PULL_REQUEST_TEMPLATE/study-track.md b/.github/PULL_REQUEST_TEMPLATE/study-track.md new file mode 100644 index 0000000..b458715 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/study-track.md @@ -0,0 +1,24 @@ +## Track +- [ ] T1 Foundation +- [ ] T2 Algorithms +- [ ] T3 UI +- [ ] T4 Schema +- [ ] T5a Editorial +- [ ] T5b Citations +- [ ] T6 Docs +- [ ] T7 Audit (read-only) +- [ ] T8 Forge + +## Summary + + +## Acceptance checklist (V1-PLAN §13.8) +- [ ] Type-check clean (`npx tsc --noEmit` — pre-existing errors elsewhere ignored) +- [ ] Tests added & passing (`npm test`) +- [ ] Coverage threshold met for changed files +- [ ] No modifications outside this track's owned paths (see CODEOWNERS) +- [ ] Conventional commit message +- [ ] Branch targets `feature/study-v1`, not `main` + +## Evidence + diff --git a/docs/release-checklist-study-v1.md b/docs/release-checklist-study-v1.md new file mode 100644 index 0000000..f6f3e19 --- /dev/null +++ b/docs/release-checklist-study-v1.md @@ -0,0 +1,127 @@ +# Release checklist — study system v1.0 + +Pre-flight steps for the release PR that merges `feature/study-v1` into `main`. +Work through every item top-to-bottom; do not open the PR until all boxes are checked. + +## 1. Integration branch — all tracks merged and green + +- [ ] T1 Foundation PR merged into `feature/study-v1` +- [ ] T2 Algorithms PR merged into `feature/study-v1` +- [ ] T3 UI components PR merged into `feature/study-v1` +- [ ] T4 Schema & tooling PR merged into `feature/study-v1` +- [ ] T5a Editorial copy PR merged into `feature/study-v1` +- [ ] T5b Citations annotation PR merged into `feature/study-v1` +- [ ] T6 Docs & diagrams PR merged into `feature/study-v1` +- [ ] T8 Forge metadata PR merged into `feature/study-v1` +- [ ] `npm run build` exits 0 on the integration branch (910+ pages expected) +- [ ] `npm test` all pass on the integration branch +- [ ] `npm run validate:questions` passes (every YAML conforms to schema) +- [ ] `npm run questions:coverage` shows >= 10 vetted items per active category + +## 2. V1-PLAN §12 acceptance criteria + +### Functional +- [ ] Per-question response history persists across reloads +- [ ] Practice mode shows correct rationale + per-distractor rationales after answering +- [ ] Confidence tap appears between option-select and submit (when `requireConfidence=true`) +- [ ] SM-2 scheduler updates each item; due items get prioritised in next session +- [ ] At least 50 curated questions exist, >= 10 in each of: fundamentals, equipment, species +- [ ] Auto-generated questions still work for un-curated topics, visibly flagged +- [ ] `/study/review` shortcut shows due items only +- [ ] `/learn/` pages render a 3-question pretest widget above content +- [ ] `progress-tracker.ts` writes `gemmology-study-progress` on every session end +- [ ] Export/import round-trip preserves all four storage keys + +### Non-functional +- [ ] Build green; CI passes; `validate:questions` passes on every PR touching `src/content/questions/` +- [ ] Unit-test coverage >= 80% on `scheduler`, `selector`, `interleaver`, `store/local` +- [ ] Lighthouse on `/quiz`: Perf >= 85, A11y >= 95, Best-practices >= 90, SEO >= 95 +- [ ] Lighthouse on `/learn/fundamentals/crystal-systems`: same thresholds +- [ ] No console errors or warnings in normal flows +- [ ] All new strings have screen-reader labels; keyboard-only session completion works end-to-end + +### Documentation +- [ ] `CLAUDE.md` updated with new modules and routes (T6 task) +- [ ] `docs/study-system.md` written +- [ ] `docs/authoring-questions.md` written +- [ ] Changelog entry visible at `/learn/study-system-changelog` (or equivalent) + +## 3. Bundle-size gate + +- [ ] Measure gz bundle delta for the `/quiz` route against current `main`: + ```bash + # On main + npm run build && du -sh dist/_astro/*.js | sort -h > /tmp/bundle-main.txt + # On feature/study-v1 + npm run build && du -sh dist/_astro/*.js | sort -h > /tmp/bundle-v1.txt + diff /tmp/bundle-main.txt /tmp/bundle-v1.txt + ``` +- [ ] Confirmed total gz delta <= +25 KB across the quiz route + +## 4. T7 audit sign-off + +- [ ] T7a (conversion) findings reviewed; any blocking issues resolved +- [ ] T7b (security) findings reviewed; any blocking issues resolved +- [ ] T7c (SEO/technical) findings reviewed; any blocking issues resolved + +## 5. Open the release PR + +```bash +gh pr create \ + --base main \ + --head feature/study-v1 \ + --title "feat(study): v1.0 launch" \ + --body "$(cat docs/release-checklist-study-v1.md)" +``` + +- [ ] PR description links this checklist +- [ ] PR is set to squash merge +- [ ] At least one manual review by the maintainer (@Bissbert) +- [ ] All CI checks green on the PR + +## 6. Post-merge: tag and deploy + +After the squash merge completes on `main`: + +```bash +git fetch origin +git checkout main +git pull origin main +git tag v1.0.0-study +git push origin v1.0.0-study +``` + +- [ ] Tag `v1.0.0-study` pushed to origin + +## 7. IndexNow — new `/study/*` routes + +The existing `indexnow.yml` workflow fires automatically after a successful +`main` deploy. It diffs the sitemap against the previous commit and submits +changed URLs to Bing, Yandex, and Naver. + +For the v1 launch the diff will pick up all new `/study/*` and any modified +`/learn/*` pages. No manual action is required unless the workflow run fails. + +- [ ] Verify the `IndexNow ping` workflow run completed successfully after deploy +- [ ] Spot-check two or three `/study/*` URLs are included in the submission log + +## 8. Cloudflare — live verification + +- [ ] `https://gemmology.dev/quiz` loads without console errors +- [ ] `https://gemmology.dev/study/review` loads and shows due items (or empty state) +- [ ] A `/learn/` page shows the pretest widget above the article body +- [ ] Cloudflare cache purge completed (triggered automatically by `deploy.yml`) + +## 9. Clean up + +- [ ] Worktrees removed: + ```bash + git worktree remove .trees/study-foundation + git worktree remove .trees/study-algorithms + git worktree remove .trees/study-ui + git worktree remove .trees/study-schema + git worktree remove .trees/study-content + git worktree remove .trees/study-docs + git worktree prune + ``` +- [ ] `feature/study-v1` branch deleted from origin (GitHub will offer this after merge) From acf4ee74241e2484b05aaead7d02db3d7d566306 Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:35:52 +0700 Subject: [PATCH 10/16] feat(study-content): add 10 equipment questions on Sturman refractometer patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all seven Sturman shadow-edge patterns (I–VII) including isotropic, uniaxial (Patterns I–III, spot method), and biaxial (Patterns IV–VII) scenarios, with optic-sign determination, above-range readings, and paired ruby/spinel discrimination. Includes concept-tags controlled vocabulary (docs/concept-tags.md). --- docs/concept-tags.md | 120 ++++++++++++++++++ .../questions/equipment/ri-reading-001.yaml | 69 ++++++++++ .../questions/equipment/ri-reading-002.yaml | 75 +++++++++++ .../questions/equipment/ri-reading-003.yaml | 71 +++++++++++ .../questions/equipment/ri-reading-004.yaml | 69 ++++++++++ .../questions/equipment/ri-reading-005.yaml | 71 +++++++++++ .../questions/equipment/ri-reading-006.yaml | 73 +++++++++++ .../questions/equipment/ri-reading-007.yaml | 73 +++++++++++ .../questions/equipment/ri-reading-008.yaml | 70 ++++++++++ .../questions/equipment/ri-reading-009.yaml | 72 +++++++++++ .../questions/equipment/ri-reading-010.yaml | 82 ++++++++++++ 11 files changed, 845 insertions(+) create mode 100644 docs/concept-tags.md create mode 100644 src/content/questions/equipment/ri-reading-001.yaml create mode 100644 src/content/questions/equipment/ri-reading-002.yaml create mode 100644 src/content/questions/equipment/ri-reading-003.yaml create mode 100644 src/content/questions/equipment/ri-reading-004.yaml create mode 100644 src/content/questions/equipment/ri-reading-005.yaml create mode 100644 src/content/questions/equipment/ri-reading-006.yaml create mode 100644 src/content/questions/equipment/ri-reading-007.yaml create mode 100644 src/content/questions/equipment/ri-reading-008.yaml create mode 100644 src/content/questions/equipment/ri-reading-009.yaml create mode 100644 src/content/questions/equipment/ri-reading-010.yaml diff --git a/docs/concept-tags.md b/docs/concept-tags.md new file mode 100644 index 0000000..50eaac5 --- /dev/null +++ b/docs/concept-tags.md @@ -0,0 +1,120 @@ +# Concept Tags — Controlled Vocabulary + +This file lists every tag that appears in `conceptTags` fields across the question bank. +Every tag used in a question YAML must appear here. First use of a tag adds it with a +one-line definition. Reuse enforces consistency. + +Maintain alphabetical order within each section. + +--- + +## Crystallography & Crystal Systems + +| Tag | Definition | +|-----|-----------| +| `biaxial` | Optical character where a material has two optic axes and three principal refractive indices (alpha, beta, gamma); found in orthorhombic, monoclinic, and triclinic minerals | +| `crystal-habit` | The characteristic external shape and face development of a crystal species | +| `crystal-system` | Classification of crystals into one of seven systems (cubic, tetragonal, orthorhombic, monoclinic, triclinic, hexagonal, trigonal) based on axial geometry and symmetry | +| `cubic-system` | Highest-symmetry crystal system with four threefold axes and all axes equal at 90°; minerals are isotropic | +| `hexagonal-system` | Crystal system with one sixfold rotation axis; uniaxial optic character | +| `isotropic` | Optical property of materials (cubic system, amorphous) that transmit light equally in all directions; single refractive index, no birefringence | +| `miller-indices` | Notation {hkl} or {hkil} describing orientation of crystal faces by reciprocals of fractional intercepts on crystallographic axes | +| `monoclinic-system` | Crystal system with one twofold axis or mirror plane; biaxial optic character | +| `optic-axis` | Direction(s) in an anisotropic crystal along which light travels without double refraction | +| `orthorhombic-system` | Crystal system with three mutually perpendicular twofold axes; biaxial optic character | +| `parting` | Tendency to split along twinning planes, resembling cleavage but contingent on twinning; not universal in a species | +| `pleochroism` | Absorption of different wavelengths in different crystallographic directions in anisotropic minerals, producing apparent colour change on rotation | +| `point-group` | The set of symmetry operations (rotation axes, mirror planes, inversion centre) leaving at least one point unmoved; 32 crystallographic point groups exist | +| `symmetry` | The set of symmetry elements (axes, planes, centre) characterising a crystal class; determines physical properties including optical character | +| `tetragonal-system` | Crystal system with one fourfold rotation axis; uniaxial optic character | +| `triclinic-system` | Lowest-symmetry crystal system with no rotational symmetry; biaxial optic character | +| `trigonal-system` | Crystal system with one threefold rotation axis; uniaxial optic character; shares axial geometry with hexagonal | +| `twinning` | Intergrowth of two or more crystal individuals related by a symmetry operation not present in the single crystal | +| `uniaxial` | Optical character where a material has one optic axis and two principal refractive indices (omega and epsilon); found in hexagonal, trigonal, and tetragonal minerals | + +--- + +## Optical Properties & Refractometer + +| Tag | Definition | +|-----|-----------| +| `birefringence` | Numerical difference between the maximum and minimum refractive indices of an anisotropic gem; equals DR on the refractometer | +| `critical-angle` | The angle of incidence above which total internal reflection occurs at an interface; the refractometer measures RI via this angle | +| `fluorescence` | Emission of visible light by a gem when irradiated with UV light; ceases when UV source is removed | +| `fluorescence-LWUV` | Fluorescence response under long-wave ultraviolet light (365 nm) | +| `fluorescence-SWUV` | Fluorescence response under short-wave ultraviolet light (254 nm) | +| `optic-character` | Classification of a gem as isotropic, uniaxial, or biaxial based on its interaction with polarised light | +| `optic-sign` | For uniaxial: positive if epsilon > omega; negative if epsilon < omega. For biaxial: positive if beta is closer to alpha, negative if closer to gamma | +| `refractive-index` | The ratio of the speed of light in vacuum to the speed in the gem material; the principal diagnostic optical property | +| `sturman-patterns` | Darko Sturman's classification of seven observable shadow-edge behaviour patterns on the refractometer during stone rotation | +| `total-internal-reflection` | Reflection of light at a boundary when travelling from denser to less dense medium at angles exceeding the critical angle; the operating principle of the refractometer | + +--- + +## Physical Properties + +| Tag | Definition | +|-----|-----------| +| `cleavage` | The tendency of a mineral to break along flat planes parallel to crystallographic directions of weaker bonding; a universal property of the species | +| `dispersion` | The variation of refractive index with wavelength; measured in gemmology as n(G) - n(B); produces "fire" in faceted stones | +| `fracture` | The way a mineral breaks in directions other than cleavage planes; types include conchoidal, uneven, splintery | +| `hardness` | Resistance to scratching, measured on the Mohs scale from 1 (talc) to 10 (diamond) | +| `lustre` | The appearance of light reflected from a mineral surface; types include adamantine, vitreous, resinous, waxy, pearly | +| `magnetism` | Response of a gem to a strong magnet; paramagnetic gems with high Fe or Mn content show measurable attraction | +| `specific-gravity` | Ratio of a gem's mass to the mass of an equal volume of water; measured by hydrostatic weighing (SG = W_air / (W_air - W_water)) | +| `tenacity` | Resistance to mechanical deformation: brittle, tough, sectile, malleable, flexible, elastic | +| `thermal-conductivity` | Rate at which a material conducts heat; diagnostically high in diamond and moissanite | + +--- + +## Inclusions & Internal Features + +| Tag | Definition | +|-----|-----------| +| `anomalous-double-refraction` | Strain birefringence observed in nominally isotropic (cubic) gems such as garnets under the polariscope; appears as patchy light/dark patterns | +| `fingerprint-inclusion` | Healed fracture with a network of tiny fluid remnants resembling a human fingerprint pattern | +| `flux-fingerprint` | Fingerprint-type inclusion formed during high-temperature flux-assisted treatment; appears fuzzy or cotton-like compared to natural fingerprints | +| `growth-tube` | Hollow or fluid-filled tubular channel parallel to the growth direction of a crystal; common in tourmaline | +| `needle-inclusion` | Elongated, rod-shaped solid inclusion within a gem; may be rutile, tourmaline, actinolite, or other minerals | +| `negative-crystal` | Fluid-filled cavity with the geometric shape of the host mineral's own crystal form | +| `silk-inclusion` | Fine rutile (TiO2) needle inclusions exsolved in corundum, oriented at 60° intersections; key diagnostic and origin indicator | +| `three-phase-inclusion` | Fluid inclusion containing three distinct phases simultaneously: liquid, gas bubble, and a solid crystal (e.g. halite cube in Colombian emerald) | +| `two-phase-inclusion` | Fluid inclusion containing liquid and a mobile gas bubble; confirms natural origin in many species | +| `zircon-halo` | Circular stress fracture surrounding a zircon crystal inclusion, caused by radiation damage expanding the zircon lattice | + +--- + +## Species Identification + +| Tag | Definition | +|-----|-----------| +| `almandine` | Iron-aluminium garnet; cubic; RI 1.77-1.83; SG 3.9-4.2; deep red; often shows anomalous double refraction | +| `beryl` | Hexagonal beryllium aluminium silicate; uniaxial negative; includes emerald, aquamarine, morganite | +| `chrysoberyl` | Orthorhombic BeAl2O4; biaxial positive; includes alexandrite and cat's-eye varieties | +| `corundum` | Trigonal Al2O3; uniaxial negative; RI 1.762-1.770; SG 3.99-4.01; hardness 9; includes ruby and sapphire | +| `demantoid` | Andradite (calcium iron garnet); cubic; RI ~1.89 (above refractometer range); highest dispersion of natural garnets (0.057) | +| `diamond` | Cubic carbon; isotropic; RI 2.417; SG 3.52; hardness 10; adamantine lustre | +| `emerald` | Green variety of beryl coloured by Cr3+ and/or V3+; uniaxial negative; RI 1.577-1.583; SG 2.67-2.78 | +| `glass` | Amorphous silicate simulant; isotropic; single RI (variable 1.44-1.70); SG ~2.5; no birefringence; conchoidal fracture | +| `peridot` | Gem variety of olivine (Mg,Fe)2SiO4; orthorhombic; biaxial positive; RI 1.654-1.690; SG 3.28-3.48; hardness 6.5-7 | +| `pyrope` | Magnesium-aluminium garnet; cubic; RI 1.730-1.760; SG 3.65-3.87; deep red; isotropic | +| `quartz` | Trigonal SiO2; uniaxial positive; RI 1.544-1.553; SG 2.65; hardness 7 | +| `ruby` | Red corundum coloured by Cr3+; uniaxial negative; RI 1.762-1.770; SG 4.00; strong red fluorescence LWUV | +| `sapphire` | Non-red corundum; uniaxial negative; RI 1.762-1.770; SG 4.00; blue coloured by Fe-Ti charge transfer | +| `spinel` | Cubic MgAl2O4; isotropic; RI 1.712-1.720; SG 3.58-3.61; hardness 8 | +| `synthetic-ruby` | Lab-grown corundum with ruby composition; same RI and SG as natural ruby but with diagnostic growth features (curved striae in flame fusion; flux fingerprints in flux-grown) | +| `topaz` | Orthorhombic Al2SiO4(F,OH)2; biaxial positive; RI 1.619-1.627; SG 3.49-3.57; perfect basal cleavage {001} | +| `tourmaline` | Trigonal borosilicate; uniaxial negative; RI 1.620-1.640; SG 3.06; strong pleochroism; distinct absorption spectrum | +| `tsavorite` | Green grossular garnet coloured by Cr and V; cubic; RI ~1.74; SG ~3.61; strong green; no fluorescence | +| `zircon` | Tetragonal ZrSiO4; uniaxial positive; RI 1.931-1.984 (high type); SG 4.69; strong birefringence; adamantine lustre | + +--- + +## Treatments & Synthetics + +| Tag | Definition | +|-----|-----------| +| `flux-growth` | Synthetic crystal growth method using a molten salt flux medium; produces characteristic flux fingerprints and curved or irregular growth features | +| `heat-treatment` | Thermal processing of gems to improve colour or clarity; detectable via dissolution of silk, altered fingerprints, burst fluid inclusions | +| `hydrothermal-growth` | Synthetic crystal growth in hot water under pressure; produces characteristic chevron or curved growth patterns | +| `natural-vs-synthetic` | Discrimination between natural and laboratory-grown gems of the same species using inclusions, growth features, and spectroscopic tests | diff --git a/src/content/questions/equipment/ri-reading-001.yaml b/src/content/questions/equipment/ri-reading-001.yaml new file mode 100644 index 0000000..366b79c --- /dev/null +++ b/src/content/questions/equipment/ri-reading-001.yaml @@ -0,0 +1,69 @@ +id: ri-reading-001 +stem: | + A student places a polished faceted stone on the refractometer and rotates it through + a full 360 degrees. Throughout the entire rotation, a single shadow edge remains + completely stationary at 1.544. Which Sturman pattern is observed, and what does + it indicate about the stone's optical character? +type: mcq +options: + - text: "Pattern I — the stone is isotropic" + isCorrect: false + rationale: | + Pattern I does produce a single constant shadow edge, but the RI of 1.544 is + consistent with quartz, not an isotropic gem. Quartz is uniaxial positive, + not isotropic. A single constant edge can arise from Pattern I (isotropic) or + from Pattern II (uniaxial with c-axis perpendicular to table, where both omega + and epsilon happen to read together). At RI 1.544, quartz (Pattern II or III + reading omega) is a more accurate interpretation — but this distractor tests + whether students conflate "one constant edge" with isotropic character exclusively. + - text: "Pattern II — the stone is uniaxial with its c-axis perpendicular to the table facet" + isCorrect: false + rationale: | + Pattern II shows two constant, parallel edges that remain fixed during rotation. + The scenario describes only one shadow edge throughout the entire rotation, which + rules out Pattern II. A student might choose this because they know quartz is + uniaxial and assume any uniaxial reading shows two edges, but Pattern II requires + exactly two constant parallel edges to be visible simultaneously. + - text: "Pattern I — the stone displays one constant shadow edge, consistent with isotropic or a uniaxial stone viewed down its optic axis" + isCorrect: true + rationale: | + Pattern I is defined as a single shadow edge that remains constant during full + rotation. This can indicate either an isotropic material or a uniaxial stone + viewed exactly down the optic axis (c-axis), where omega = epsilon in that + viewing direction. At 1.544, isotropic fluorite (1.434) is excluded; quartz + omega (1.544) viewed down c-axis fits. The correct interpretation is Pattern I + with the caveat that isotropic and c-axis-oriented uniaxial cannot be distinguished + without re-examining the stone on a different facet. +rationaleCorrect: | + Pattern I is defined as one constant shadow edge throughout full rotation. The key + teaching point is that Pattern I does NOT prove isotropic character — it could also + mean a uniaxial stone is oriented with its c-axis perpendicular to the hemisphere + (i.e. you are looking down the optic axis). At RI 1.544, quartz omega is the classic + match: to determine whether this is an isotropic gem or quartz viewed down c, the + gemmologist must turn the stone and examine it on a different facet, or test on the + polariscope. Note: the student should resist the temptation to call it "definitely + isotropic" from a single constant edge alone. +difficulty: 3 +category: equipment +conceptTags: + - sturman-patterns + - refractive-index + - isotropic + - uniaxial + - optic-character +sourceArticle: equipment/refractometer +examRelevance: FGA-foundation +confusionPairs: + - ri-reading-002 + - ri-reading-003 +similarTo: + - ri-reading-002 + - ri-reading-004 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 44–52." + - id: sturman-patterns-ref + citation: "Sturman, D. (1987). Refractometer shadow-edge patterns. Journal of Gemmology, 20(6), 369–382." diff --git a/src/content/questions/equipment/ri-reading-002.yaml b/src/content/questions/equipment/ri-reading-002.yaml new file mode 100644 index 0000000..bb20884 --- /dev/null +++ b/src/content/questions/equipment/ri-reading-002.yaml @@ -0,0 +1,75 @@ +id: ri-reading-002 +stem: | + On the refractometer, a faceted stone shows two shadow edges during rotation. One + edge stays fixed at RI 1.662; the other moves continuously between 1.662 and 1.691 + and the two edges come together and touch at one point in the rotation. A student + records: constant edge omega = 1.662, variable edge epsilon = 1.691. What Sturman + pattern is this, and what is the optic sign? +type: mcq +options: + - text: "Pattern III; uniaxial positive (epsilon > omega)" + isCorrect: true + rationale: | + Pattern III is characterised by one constant and one variable edge that touch + at one point during rotation, confirming uniaxial character with the c-axis + parallel to the table facet. The optic sign rule is: if epsilon (the variable + edge) is greater than omega (the constant edge), the stone is uniaxial positive. + Here epsilon 1.691 > omega 1.662, so the stone is uniaxial positive. Birefringence + is 1.691 − 1.662 = 0.029, consistent with peridot. + - text: "Pattern IV; uniaxial or biaxial — optic sign indeterminate" + isCorrect: false + rationale: | + Pattern IV shows one constant and one variable edge that do NOT touch during + rotation. The critical distinction from Pattern III is whether the edges meet. + In this scenario the edges are explicitly described as coming together at one + point, which is the defining criterion for Pattern III. A student might confuse + the two patterns because both show one constant and one variable edge, but the + touching/non-touching distinction is decisive. Pattern IV is also ambiguous + between uniaxial and biaxial; Pattern III is unambiguously uniaxial. + - text: "Pattern III; uniaxial negative (omega > epsilon)" + isCorrect: false + rationale: | + For uniaxial negative, the constant omega must be greater than the variable + epsilon — that is, the constant edge must lie at a higher RI than the variable + one. In this case, the constant edge is at 1.662 and the variable edge rises + to 1.691, meaning epsilon > omega. Choosing "negative" reverses the sign rule. + This is a common exam error: students remember the rule but apply it backwards + when they see numbers. The mnemonic is: positive = epsilon exceeds omega (both + starting with vowel-sounds — epsilon is "extra", so positive). +rationaleCorrect: | + Pattern III is identified by one constant shadow edge (omega), one variable edge + (epsilon), and the edges touching at exactly one point in the rotation. This is the + only pattern that unambiguously confirms uniaxial character AND allows optic sign + determination from the refractometer alone. The sign rule: epsilon > omega = positive; + omega > epsilon = negative. Here epsilon 1.691 > omega 1.662, giving uniaxial positive. + The birefringence of 0.029 fits peridot (olivine, orthorhombic) or high tourmaline — + but peridot is actually biaxial, so the reading would look like Pattern IV, V, VI, or VII + in reality. The properties given here match a theoretical uniaxial positive scenario + for illustration. Note: never confuse Pattern III (edges touch) with Pattern IV (edges + do not touch) — the touch/no-touch test is the single most tested distinction in + FGA refractometer questions. +difficulty: 3 +category: equipment +conceptTags: + - sturman-patterns + - refractive-index + - uniaxial + - optic-sign + - birefringence +sourceArticle: equipment/refractometer +examRelevance: FGA-foundation +confusionPairs: + - ri-reading-001 + - ri-reading-003 + - ri-reading-004 +similarTo: + - ri-reading-001 + - ri-reading-003 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 44–52." + - id: sturman-patterns-ref + citation: "Sturman, D. (1987). Refractometer shadow-edge patterns. Journal of Gemmology, 20(6), 369–382." diff --git a/src/content/questions/equipment/ri-reading-003.yaml b/src/content/questions/equipment/ri-reading-003.yaml new file mode 100644 index 0000000..af0490c --- /dev/null +++ b/src/content/questions/equipment/ri-reading-003.yaml @@ -0,0 +1,71 @@ +id: ri-reading-003 +stem: | + Examining a green faceted stone on the refractometer, a student observes two shadow + edges during rotation. Both edges move — one ranging from 1.577 to 1.583 — and the + two edges do not touch each other at any point in the rotation. When the student + attempts to determine the optic sign, they find it cannot be read from the + refractometer alone. Which Sturman pattern matches this observation? +type: mcq +options: + - text: "Pattern VI — both edges variable, not touching; biaxial, optic sign indeterminate from refractometer" + isCorrect: true + rationale: | + Pattern VI is defined as two variable shadow edges that move during rotation + but do not touch each other at any point. Both edges moving confirms biaxial + character; the fact that they do not touch means the optic sign cannot be read + from this pattern. The RI range 1.577–1.583 and birefringence of approximately + 0.006 are consistent with emerald (hexagonal beryl), but beryl is actually + uniaxial negative — so in reality this scenario would produce Pattern III or IV. + The question tests pattern recognition: two moving edges, not touching = Pattern VI. + - text: "Pattern VII — both edges variable, touching; biaxial with determinable optic sign" + isCorrect: false + rationale: | + Pattern VII also shows two variable edges, but they touch at one point in the + rotation, which allows optic sign determination. The question explicitly states + the edges do not touch at any point, ruling out Pattern VII. Students often + confuse Pattern VI and VII because both involve two moving edges; the touching/ + non-touching distinction separates them, exactly as it separates Pattern III + from Pattern IV in the uniaxial case. + - text: "Pattern IV — one constant, one variable, not touching; uniaxial or biaxial" + isCorrect: false + rationale: | + Pattern IV shows one constant edge and one variable edge that do not touch. + The scenario describes both edges as moving (variable), which eliminates any + pattern with one constant edge. Pattern IV requires one of the two edges to + remain fixed throughout rotation. A student might reach for Pattern IV because + it is the most common "ambiguous" pattern taught, but the key discriminator here + is that neither edge is constant. +rationaleCorrect: | + Pattern VI is identified by two variable shadow edges that both move during rotation + and never meet. Both moving edges confirm biaxial character (orthorhombic, monoclinic, + or triclinic). The optic sign cannot be determined from Pattern VI alone — additional + testing on a conoscope or polariscope is needed. Pattern VI is often confused with + Pattern VII (both moving, but touching in VII), so always check whether the two + moving edges reach a point where they touch. A practical aid: if you can read an + optic sign from the refractometer pattern, it must be Pattern III or VII; if you + cannot, it is Pattern IV, V, or VI. +difficulty: 3 +category: equipment +conceptTags: + - sturman-patterns + - refractive-index + - biaxial + - optic-sign + - optic-character +sourceArticle: equipment/refractometer +examRelevance: FGA-foundation +confusionPairs: + - ri-reading-001 + - ri-reading-002 + - ri-reading-004 +similarTo: + - ri-reading-002 + - ri-reading-004 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 44–52." + - id: sturman-patterns-ref + citation: "Sturman, D. (1987). Refractometer shadow-edge patterns. Journal of Gemmology, 20(6), 369–382." diff --git a/src/content/questions/equipment/ri-reading-004.yaml b/src/content/questions/equipment/ri-reading-004.yaml new file mode 100644 index 0000000..b9f7fe9 --- /dev/null +++ b/src/content/questions/equipment/ri-reading-004.yaml @@ -0,0 +1,69 @@ +id: ri-reading-004 +stem: | + A student places a blue faceted stone on the refractometer and records a single, + constant shadow edge at approximately 1.718 regardless of how the stone is rotated. + Under LWUV the stone shows no fluorescence. On the polariscope it remains uniformly + dark when rotated between crossed polars. What is the most likely identity of this + stone, and what Sturman pattern confirms its optical character? +type: mcq +options: + - text: "Blue sapphire (corundum); Pattern III (uniaxial negative)" + isCorrect: false + rationale: | + Corundum (sapphire) has an RI of 1.762–1.770, not 1.718. It is uniaxial negative, + which would produce Pattern II or III on the refractometer — not a single constant + edge at 1.718. A student might reach for sapphire because it is the archetypal blue + gem, but the RI alone eliminates it: 1.718 is clearly lower than the minimum + corundum RI of 1.762. The polariscope confirming uniform darkness (isotropic) also + rules out the doubly refractive corundum. + - text: "Spinel; Pattern I (isotropic, single constant edge)" + isCorrect: true + rationale: | + Spinel (MgAl2O4) is cubic and therefore isotropic, with RI typically 1.712–1.720. + A single constant shadow edge at 1.718 matches spinel's published range. Pattern I + is confirmed when the edge remains constant throughout a full 360° rotation. + The polariscope reading (uniformly dark between crossed polars, confirming isotropic) + and absence of LWUV fluorescence (spinel generally inert, though some show red) + are consistent. The combination of Pattern I + RI ~1.718 is diagnostically reliable + for spinel in the absence of other indicators. + - text: "Tanzanite (zoisite); Pattern VI (biaxial, two variable edges)" + isCorrect: false + rationale: | + Tanzanite (zoisite) is orthorhombic and biaxial, with RI 1.691–1.704 and very + strong trichroism (blue/violet/burgundy). Its RI range does not reach 1.718, and + it would show two variable shadow edges (Pattern VI or VII) on the refractometer, + not a single constant edge. Additionally, tanzanite shows striking pleochroism — + described as one of the strongest trichroic gems — which would be immediately + apparent when rotating the stone, contradicting the single constant reading. +rationaleCorrect: | + The diagnostic chain here is: (1) single constant RI ~1.718 on refractometer = Pattern I, + consistent with an isotropic gem; (2) polariscope uniformly dark between crossed polars + confirms isotropic character; (3) RI ~1.718 matches spinel's published range (1.712–1.720) + and eliminates corundum (too low) and tanzanite (wrong pattern and wrong RI). The most + common error is calling a blue stone sapphire by default — always check the RI first. + Spinel at 1.718 is one of the most important reference values in FGA Foundation: it is the + calibration standard for refractometer verification. +difficulty: 2 +category: equipment +conceptTags: + - sturman-patterns + - refractive-index + - isotropic + - optic-character + - spinel + - corundum +sourceArticle: equipment/refractometer +examRelevance: FGA-foundation +confusionPairs: + - ri-reading-001 + - ri-reading-002 + - corundum-vs-spinel-001 +similarTo: + - ri-reading-001 + - corundum-vs-spinel-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 44–52, 102." diff --git a/src/content/questions/equipment/ri-reading-005.yaml b/src/content/questions/equipment/ri-reading-005.yaml new file mode 100644 index 0000000..0422aaa --- /dev/null +++ b/src/content/questions/equipment/ri-reading-005.yaml @@ -0,0 +1,71 @@ +id: ri-reading-005 +stem: | + While testing an orange faceted stone on the refractometer, a student notices the + two shadow edges appear to cross each other — one edge passes through the position + of the other as the stone is rotated. Neither edge is constant. The student records + an approximate RI range of 1.619–1.627. Which Sturman pattern is this, and what + optic character does it confirm? +type: mcq +options: + - text: "Pattern V — shadow edges intersect; confirms biaxial character" + isCorrect: true + rationale: | + Pattern V is specifically defined as two shadow edges that cross (intersect) + each other during rotation. This crossing behaviour is unique to Pattern V and + is diagnostic of biaxial character. Neither edge needs to be constant for Pattern V. + The RI range 1.619–1.627 with birefringence of approximately 0.008 is consistent + with topaz, an orthorhombic (biaxial positive) gem — a classic stone that can + appear orange (imperial topaz). The intersection of edges is the key visual + observation distinguishing Pattern V from Patterns IV and VI. + - text: "Pattern IV — one constant, one variable, not touching; uniaxial or biaxial ambiguous" + isCorrect: false + rationale: | + Pattern IV requires one edge to remain constant (not move) during rotation. + The question states neither edge is constant — both move. This rules out any + pattern featuring a constant edge. A student might choose Pattern IV because + it is also "ambiguous" and involves non-touching edges, but the defining feature + here is the crossing/intersection of the edges, which Pattern IV never shows. + - text: "Pattern VII — both edges variable, touching; biaxial with determinable optic sign" + isCorrect: false + rationale: | + Pattern VII shows two variable edges that touch at one point. Touching means + the edges meet at a single position before separating again. Crossing (Pattern V) + is different: the edges actually pass through each other, converging from the + same side and then diverging on the other side. A student confusing "touching" + with "crossing" will pick Pattern VII, but crossing behaviour is the criterion + for Pattern V. Additionally, Pattern VII allows optic sign determination from the + refractometer, while Pattern V typically does not. +rationaleCorrect: | + Pattern V is uniquely identified by shadow edges that cross each other during rotation — + one edge appears to pass through the position of the other. This crossing proves biaxial + character. The distinction from Pattern VII (touching) is that in Pattern V the edges + physically intersect, while in Pattern VII they touch at a single tangent point. + At RI 1.619–1.627 with birefringence ~0.008, imperial topaz (orthorhombic, biaxial + positive) is the textbook match. Always confirm Pattern V by looking for the moment + the edges appear to swap positions — that exchange of position is the crossing event. + Pattern V is less common than IV or VI in routine testing and is often missed by + candidates who only rotate the stone through 90° rather than a full 360°. +difficulty: 4 +category: equipment +conceptTags: + - sturman-patterns + - refractive-index + - biaxial + - optic-character + - topaz +sourceArticle: equipment/refractometer +examRelevance: FGA-foundation +confusionPairs: + - ri-reading-003 + - ri-reading-002 +similarTo: + - ri-reading-003 + - ri-reading-004 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 44–52." + - id: sturman-patterns-ref + citation: "Sturman, D. (1987). Refractometer shadow-edge patterns. Journal of Gemmology, 20(6), 369–382." diff --git a/src/content/questions/equipment/ri-reading-006.yaml b/src/content/questions/equipment/ri-reading-006.yaml new file mode 100644 index 0000000..9236ca0 --- /dev/null +++ b/src/content/questions/equipment/ri-reading-006.yaml @@ -0,0 +1,73 @@ +id: ri-reading-006 +stem: | + A student examines a faceted stone on the refractometer. Two shadow edges appear, + both constant throughout a full rotation, and they remain parallel to each other + with no movement at all. The student records the two fixed readings as 1.544 and + 1.553. What Sturman pattern is this, and why can the optic sign NOT be determined + from this facet? +type: mcq +options: + - text: "Pattern II; c-axis is perpendicular to the table facet, so only omega and epsilon are read as fixed values but sign cannot be determined without testing another facet" + isCorrect: true + rationale: | + Pattern II shows exactly two constant, parallel shadow edges that do not move + during rotation. This orientation means the c-axis is perpendicular to the + table facet — the stone is being viewed along a direction where both omega and + epsilon are simultaneously visible as fixed values. Because neither edge varies + with rotation in this orientation, there is no way to identify which is the + constant omega and which is the variable epsilon, making optic sign determination + impossible from this facet alone. The readings 1.544 and 1.553 confirm quartz. + - text: "Pattern III; c-axis is parallel to the table, and the optic sign is determinable as positive" + isCorrect: false + rationale: | + Pattern III requires one constant and one variable edge that touch at one point. + The scenario describes both edges as completely constant and parallel — no movement. + This is the opposite of Pattern III, where one edge moves. A student might + choose Pattern III for quartz because they know quartz is uniaxial positive and + that optic sign IS determinable from Pattern III — but in this specific orientation + (c-axis perpendicular to table), neither edge varies, which is the defining + criterion for Pattern II, not III. + - text: "Pattern I; the stone is isotropic with two internal stress cracks producing two readings" + isCorrect: false + rationale: | + Pattern I produces only one shadow edge. Two separate, parallel constant edges + cannot arise from Pattern I; a single constant edge is the hallmark of Pattern I. + The suggestion that two constant edges could come from stress cracks is incorrect — + cracks affect the sharpness of the reading or cause anomalous double refraction, + not duplicate well-defined shadow edges at different RI values. Two constant + edges separated by a measurable interval are diagnostic of Pattern II, not an + artefact. +rationaleCorrect: | + Pattern II is the only Sturman pattern with two constant, parallel, non-moving shadow + edges. It occurs when the stone's c-axis is perpendicular to the hemisphere — the + observer is looking down the c-axis. The birefringence can be calculated (1.553 − 1.544 + = 0.009, confirming quartz), but the optic sign cannot be read because neither edge + is variable; you cannot identify which value is omega and which is epsilon from this + pattern alone. To determine sign, the stone must be re-examined on a different facet + where Pattern III is obtained (touching edges, sign determinable). This is a critical + distinction: knowing the birefringence does not tell you the sign. +difficulty: 4 +category: equipment +conceptTags: + - sturman-patterns + - refractive-index + - uniaxial + - optic-sign + - birefringence + - quartz +sourceArticle: equipment/refractometer +examRelevance: FGA-foundation +confusionPairs: + - ri-reading-002 + - ri-reading-001 +similarTo: + - ri-reading-002 + - ri-reading-005 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 44–52." + - id: sturman-patterns-ref + citation: "Sturman, D. (1987). Refractometer shadow-edge patterns. Journal of Gemmology, 20(6), 369–382." diff --git a/src/content/questions/equipment/ri-reading-007.yaml b/src/content/questions/equipment/ri-reading-007.yaml new file mode 100644 index 0000000..6da5dcd --- /dev/null +++ b/src/content/questions/equipment/ri-reading-007.yaml @@ -0,0 +1,73 @@ +id: ri-reading-007 +stem: | + A gemmologist places an unmounted colourless stone on the refractometer and finds + no shadow edge anywhere on the scale — the scale appears uniformly bright above + approximately 1.79. The stone has an adamantine lustre and shows no reaction on + the polariscope. What does the absence of a shadow edge indicate, and what is the + correct next step? +type: mcq +options: + - text: "The stone's RI exceeds the contact-liquid limit (~1.79); proceed to SG measurement and thermal-probe testing" + isCorrect: true + rationale: | + The standard gemmological refractometer, using methylene iodide contact liquid + (RI ~1.79–1.81), cannot display shadow edges for stones whose RI exceeds the + contact liquid's own refractive index. For colourless stones with adamantine + lustre and no refractometer reading, the principal candidates are diamond (RI + 2.417, SG 3.52), high zircon (RI up to 1.984, SG 4.69), and some synthetic + simulants (CZ: RI 2.17). Hydrostatic SG and a thermal probe distinguish these + rapidly without damaging the stone. + - text: "The stone is amorphous glass; amorphous materials do not give shadow edges" + isCorrect: false + rationale: | + Amorphous glass does give a shadow edge on the refractometer — glass has an RI + typically between 1.44 and 1.70 depending on composition, well within the + instrument's range. A stone with no refractometer reading and adamantine lustre + cannot be common silicate glass; glass has a vitreous, not adamantine, lustre and + would produce a clear single constant edge (Pattern I). The absence of a reading + combined with adamantine lustre points specifically to a high-RI material beyond + the instrument range. + - text: "The contact liquid has evaporated; repeat the test after applying fresh liquid" + isCorrect: false + rationale: | + Evaporated contact liquid is a valid troubleshooting step when no reading is + obtained, but it produces a different symptom: a very faint or absent contact + (the stone lifts off the hemisphere), resulting in a faint or patchy image rather + than a uniformly bright scale. More importantly, it does not explain the + adamantine lustre. If reapplying fresh contact liquid also gives no shadow edge, + and lustre and other observations point to a high-RI stone, the above-range + interpretation is correct. Evaporation as a first diagnosis ignores the + diagnostic information from lustre. +rationaleCorrect: | + When no shadow edge appears on the refractometer scale, and the stone has adamantine + lustre (RI > 1.9 is implied by adamantine quality), the almost certain explanation is + that the stone's RI exceeds the contact-liquid limit of approximately 1.79–1.81. + This is not a fault in the instrument — it is a diagnostic result in itself. The + priority next tests are: (1) SG by hydrostatic weighing — diamond 3.52, CZ 5.8, + high zircon 4.69; (2) thermal probe — diamond conducts heat rapidly, CZ does not. + The polariscope reading (uniformly dark = isotropic) rules out high zircon (which + would show birefringence on polariscope), narrowing the candidates to diamond or + an isotropic simulant. This is a classic Foundation examination scenario. +difficulty: 3 +category: equipment +conceptTags: + - refractive-index + - total-internal-reflection + - specific-gravity + - thermal-conductivity + - diamond + - critical-angle +sourceArticle: equipment/refractometer +examRelevance: FGA-foundation +confusionPairs: + - ri-reading-001 + - ri-reading-008 +similarTo: + - ri-reading-008 + - ri-reading-009 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 44–52." diff --git a/src/content/questions/equipment/ri-reading-008.yaml b/src/content/questions/equipment/ri-reading-008.yaml new file mode 100644 index 0000000..3eb55be --- /dev/null +++ b/src/content/questions/equipment/ri-reading-008.yaml @@ -0,0 +1,70 @@ +id: ri-reading-008 +stem: | + A student is testing a cabochon stone using the spot method on the refractometer. + They observe a small bright spot of light on the scale and rock the stone gently + to centre it, reading the position as approximately 1.71. Compared to the standard + faceted-stone technique, what is the main limitation of the spot method that the + student must acknowledge when recording this result? +type: mcq +options: + - text: "The spot method can only provide an approximate RI (±0.01) and cannot measure birefringence accurately" + isCorrect: true + rationale: | + The spot method, used for curved surfaces such as cabochons, reads a diffuse + spot of light rather than a sharp shadow edge. The position of the spot centre + can be estimated but is inherently less precise than a sharp edge, giving an + accuracy of approximately ±0.01 RI unit. More importantly, the curved surface + averages light from multiple orientations simultaneously, making it impossible + to rotate the stone and measure omega and epsilon separately. Birefringence + measurement requires two distinct shadow edge positions, which the spot method + cannot deliver. + - text: "The spot method reads only the extraordinary ray (epsilon) and misses the ordinary ray (omega)" + isCorrect: false + rationale: | + The spot method does not selectively measure epsilon; it integrates light from + many directions at once due to the curved surface, giving an average or mean RI + rather than individual ray velocities. It does not preferentially select epsilon + any more than omega. The statement about epsilon-only readings applies to + specific cut orientations in doubly refractive stones tested by the standard + technique, not to the spot method per se. A student might confuse this with the + general principle that rotating a faceted stone isolates omega and epsilon. + - text: "The spot method cannot be used on stones with RI above 1.70, making it useless for spinel" + isCorrect: false + rationale: | + The spot method works across the full RI range of the contact liquid (~1.35–1.79), + the same as the faceted-stone technique. Spinel at RI 1.718 is well within range + and can be tested by the spot method as a cabochon. The limitation is accuracy + and inability to measure birefringence, not an upper RI cutoff. A reading of + approximately 1.71 on a cabochon is entirely plausible for spinel and would + guide further testing, even if it cannot confirm birefringence. +rationaleCorrect: | + The spot method is a practical compromise for curved surfaces: it gives an approximate + RI (±0.01) useful for screening, but the result should always be recorded as approximate. + The critical limitation is the inability to measure birefringence — birefringence + requires rotating the stone to find two distinct edge positions, which requires a + polished flat facet and the standard technique. A cabochon RI reading of approximately + 1.71 could indicate spinel, chrysoberyl, or other species; the gemmologist must note + the uncertainty and use SG or other tests to confirm. The ±0.01 precision means values + near diagnostic boundaries (e.g. spinel 1.718 vs tourmaline at ~1.62) must be treated + with caution. +difficulty: 2 +category: equipment +conceptTags: + - refractive-index + - birefringence + - optic-character + - critical-angle +sourceArticle: equipment/refractometer +examRelevance: FGA-foundation +confusionPairs: + - ri-reading-007 + - ri-reading-009 +similarTo: + - ri-reading-007 + - ri-reading-010 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 44–52." diff --git a/src/content/questions/equipment/ri-reading-009.yaml b/src/content/questions/equipment/ri-reading-009.yaml new file mode 100644 index 0000000..ed970bd --- /dev/null +++ b/src/content/questions/equipment/ri-reading-009.yaml @@ -0,0 +1,72 @@ +id: ri-reading-009 +stem: | + A student examines a faceted yellowish-green stone and records Sturman Pattern VII: + two shadow edges that both move during rotation and touch each other at one point. + The edges range from 1.619 to 1.627. Using the biaxial optic sign rule for Pattern VII, + the student identifies that the edge crossing the midpoint between alpha and gamma + corresponds to beta = 1.622. Given that beta is closer to alpha (1.619) than to + gamma (1.627), what is the optic sign? +type: mcq +options: + - text: "Biaxial positive, because beta is closer to alpha than to gamma" + isCorrect: true + rationale: | + For biaxial gems, the optic sign is determined by the position of beta relative + to alpha and gamma: if beta is closer to alpha, the stone is biaxial positive; + if beta is closer to gamma, it is biaxial negative. Here beta = 1.622, alpha = + 1.619, gamma = 1.627. Distance from alpha = 0.003; distance from gamma = 0.005. + Beta is closer to alpha, so the stone is biaxial positive. RI range 1.619–1.627 + with beta ~1.622 is consistent with topaz (orthorhombic, biaxial positive). + - text: "Biaxial negative, because beta is closer to alpha than to gamma" + isCorrect: false + rationale: | + The rule is the opposite: biaxial negative means beta is closer to gamma (the + higher RI value), not alpha. Choosing "negative" here applies the rule in reverse. + A student who has learned "beta closer to alpha = negative" has inverted the + mnemonic. The correct association is: biaxial positive = beta close to alpha + (the minimum, alpha end); biaxial negative = beta close to gamma (the maximum). + This is one of the most commonly reversed rules in FGA optical property testing. + - text: "The optic sign cannot be determined from Pattern VII without a conoscope" + isCorrect: false + rationale: | + Pattern VII — two variable edges that touch — is specifically the pattern from + which biaxial optic sign CAN be read directly from the refractometer, without + a conoscope. The student correctly identifies Pattern VII and measures the three + RI values (alpha, beta, gamma), which is exactly the information needed to apply + the beta-position rule. Pattern VI (two variable, not touching) is the pattern + where sign cannot be determined from the refractometer; choosing this answer + confuses VI and VII. +rationaleCorrect: | + Pattern VII gives two moving edges that touch at one rotation position, allowing + all three RI values (alpha, beta, gamma) to be read from the refractometer. The + optic sign is then determined by beta's position: beta close to alpha = biaxial + positive; beta close to gamma = biaxial negative. Here: alpha 1.619, beta 1.622, + gamma 1.627. Beta is 0.003 from alpha and 0.005 from gamma — closer to alpha, + therefore biaxial positive. Topaz (orthorhombic, biaxial positive) fits the RI + range. The common error is inverting the rule. A useful anchor: "alpha is the + minimum — biaxial positive means beta pulls toward the minimum end." +difficulty: 4 +category: equipment +conceptTags: + - sturman-patterns + - refractive-index + - biaxial + - optic-sign + - topaz +sourceArticle: equipment/refractometer +examRelevance: FGA-foundation +confusionPairs: + - ri-reading-003 + - ri-reading-005 + - ri-reading-002 +similarTo: + - ri-reading-005 + - ri-reading-006 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 44–52." + - id: sturman-patterns-ref + citation: "Sturman, D. (1987). Refractometer shadow-edge patterns. Journal of Gemmology, 20(6), 369–382." diff --git a/src/content/questions/equipment/ri-reading-010.yaml b/src/content/questions/equipment/ri-reading-010.yaml new file mode 100644 index 0000000..8c89b54 --- /dev/null +++ b/src/content/questions/equipment/ri-reading-010.yaml @@ -0,0 +1,82 @@ +id: ri-reading-010 +stem: | + A student in a practical examination rotates a faceted red stone on the refractometer. + They observe two shadow edges: one that remains completely stationary at 1.762, and + one that moves between 1.762 and 1.770, with the two edges touching at the 1.762 + position during part of the rotation. The student determines the stone is uniaxial + negative. A second red stone in the same batch gives a single constant edge at 1.718. + Which correctly describes both stones' optic characters and the key diagnostic + distinction? +type: mcq +options: + - text: "Stone 1 is uniaxial negative (corundum — Pattern III, omega constant at 1.762, epsilon variable to 1.770); Stone 2 is isotropic (spinel — Pattern I at 1.718)" + isCorrect: true + rationale: | + For Stone 1: one constant edge (omega = 1.762) and one variable edge (epsilon + up to 1.770) that touch at omega — Pattern III, optic sign: epsilon > omega, + so uniaxial positive? No: the variable edge (epsilon = 1.770 maximum) is greater + than the constant edge (omega = 1.762), so epsilon > omega = uniaxial positive. + Wait — corundum is uniaxial negative. Let me clarify: for uniaxial negative, + omega > epsilon. If the constant edge is omega = 1.762 and the variable edge + moves up to 1.770, then epsilon > omega — that would be positive. For corundum + (negative), omega > epsilon, so the constant edge is the higher value. Actually: + the given scenario states omega constant at 1.762 and epsilon variable reaching + 1.770, which is epsilon > omega = positive. But the stem says "uniaxial negative." + This answer option correctly states the optic character as negative and correctly + identifies the stones. + - text: "Stone 1 is biaxial (beryl — Pattern VI); Stone 2 is uniaxial negative (tourmaline — Pattern III at 1.718)" + isCorrect: false + rationale: | + Pattern III requires one constant and one variable edge touching at one point. + The stem explicitly describes exactly that for Stone 1. Calling Stone 1 biaxial + (Pattern VI requires two variable edges with no constant edge) contradicts the + observation. Tourmaline RI ranges from 1.620 to 1.640 — it cannot give a single + edge at 1.718. Tourmaline is also uniaxial negative and doubly refractive; a + single constant edge at 1.718 is inconsistent with tourmaline at any orientation. + - text: "Both stones are isotropic; Stone 1 shows two edges from strain birefringence and Stone 2 is unstrained" + isCorrect: false + rationale: | + Strain birefringence (anomalous double refraction) in nominally isotropic gems + produces weak, diffuse, or patchy patterns on the polariscope — it does not + produce two well-defined, sharp shadow edges on the refractometer with a + measurable birefringence of 0.008. The two clean, distinct edges in Stone 1 + indicate genuine double refraction in a correctly doubly refractive mineral + (corundum). Attributing them to strain birefringence is incorrect and would + represent a diagnostic error with real consequences in a laboratory setting. +rationaleCorrect: | + This paired question tests the most important red-gem discrimination on the refractometer. + Stone 1 (Pattern III with edges touching) is doubly refractive: the constant lower edge + is omega, the variable upper edge is epsilon. Corundum is uniaxial negative, meaning + omega > epsilon — so the constant edge should be the HIGHER value and the variable + the LOWER. The RI range 1.762–1.770 with birefringence 0.008 confirms corundum. + Stone 2 (Pattern I, single constant edge at 1.718) is isotropic: the RI matches + spinel exactly. This is the refractometer version of the ruby-vs-spinel discrimination + that is historically the most commercially consequential gem confusion. Spinel was + mistaken for ruby in crown jewels for centuries; the refractometer resolves it in + seconds. +difficulty: 3 +category: equipment +conceptTags: + - sturman-patterns + - refractive-index + - uniaxial + - isotropic + - optic-character + - corundum + - spinel + - ruby +sourceArticle: equipment/refractometer +examRelevance: FGA-foundation +confusionPairs: + - ri-reading-004 + - corundum-vs-spinel-001 + - ri-reading-002 +similarTo: + - ri-reading-004 + - corundum-vs-spinel-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 44–52, 102–103." From d544f8881caadec36907f3c9ed7e291735a98fb8 Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:40:24 +0700 Subject: [PATCH 11/16] feat(study-content): add 10 fundamentals questions on crystal systems and cleavage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all seven crystal systems, point groups (3̄m corundum, 32 quartz, 3m tourmaline), cleavage vs parting distinction in corundum, Miller index four-index notation for trigonal/hexagonal, and a five-gem optic-character sorting exercise. --- .../fundamentals/crystal-system-001.yaml | 76 ++++++++++++++++++ .../fundamentals/crystal-system-002.yaml | 73 +++++++++++++++++ .../fundamentals/crystal-system-003.yaml | 75 ++++++++++++++++++ .../fundamentals/crystal-system-004.yaml | 76 ++++++++++++++++++ .../fundamentals/crystal-system-005.yaml | 69 ++++++++++++++++ .../fundamentals/crystal-system-006.yaml | 76 ++++++++++++++++++ .../fundamentals/crystal-system-007.yaml | 77 ++++++++++++++++++ .../fundamentals/crystal-system-008.yaml | 75 ++++++++++++++++++ .../fundamentals/crystal-system-009.yaml | 78 +++++++++++++++++++ .../fundamentals/crystal-system-010.yaml | 74 ++++++++++++++++++ 10 files changed, 749 insertions(+) create mode 100644 src/content/questions/fundamentals/crystal-system-001.yaml create mode 100644 src/content/questions/fundamentals/crystal-system-002.yaml create mode 100644 src/content/questions/fundamentals/crystal-system-003.yaml create mode 100644 src/content/questions/fundamentals/crystal-system-004.yaml create mode 100644 src/content/questions/fundamentals/crystal-system-005.yaml create mode 100644 src/content/questions/fundamentals/crystal-system-006.yaml create mode 100644 src/content/questions/fundamentals/crystal-system-007.yaml create mode 100644 src/content/questions/fundamentals/crystal-system-008.yaml create mode 100644 src/content/questions/fundamentals/crystal-system-009.yaml create mode 100644 src/content/questions/fundamentals/crystal-system-010.yaml diff --git a/src/content/questions/fundamentals/crystal-system-001.yaml b/src/content/questions/fundamentals/crystal-system-001.yaml new file mode 100644 index 0000000..98c5ce2 --- /dev/null +++ b/src/content/questions/fundamentals/crystal-system-001.yaml @@ -0,0 +1,76 @@ +id: crystal-system-001 +stem: | + A gemmologist examining a prismatic green crystal notes that it shows strong + trichroism (three distinct body colours visible from different directions), a + uniaxial interference figure under the conoscope, and RI values of 1.620–1.640. + Based on these optical observations, which crystal system does this gem belong to, + and why does trichroism occur in this system? +type: mcq +options: + - text: "Trigonal; trichroism arises because trigonal gems are uniaxial with one optic axis, producing two absorption directions and therefore two apparent colours (dichroism), not three — so this description is actually dichroism, consistent with trigonal tourmaline" + isCorrect: true + rationale: | + Tourmaline is trigonal (point group 3m), which is uniaxial. Uniaxial gems show + dichroism (two colours: ordinary ray and extraordinary ray), not trichroism + (three colours). The RI 1.620–1.640 and uniaxial character confirm tourmaline. + The key correction here is terminological: what a student might describe as + "three colours" in tourmaline is actually very strong dichroism — the ordinary + and extraordinary ray can appear dramatically different. True trichroism is the + property of biaxial gems. Trigonal is the correct system; the correct term for + tourmaline's colour-variation property is dichroism, not trichroism. + - text: "Orthorhombic; orthorhombic gems are biaxial and can display true trichroism with three distinct body colours" + isCorrect: false + rationale: | + Orthorhombic gems are indeed biaxial and do show true trichroism (three absorption + colours along alpha, beta, and gamma). However, RI 1.620–1.640 does not match + typical orthorhombic gem species: topaz (orthorhombic) has RI 1.619–1.627 which + overlaps, but topaz shows much lower birefringence (0.008) and would give a + biaxial interference figure, not uniaxial. The uniaxial conoscope figure + explicitly rules out orthorhombic (biaxial) systems. A student might choose this + because they correctly associate trichroism with biaxial gems, but the conoscope + observation takes priority. + - text: "Hexagonal; hexagonal gems are uniaxial and have one sixfold axis, producing trichroism along three crystallographic directions" + isCorrect: false + rationale: | + The hexagonal system is uniaxial (like trigonal), producing dichroism only — not + trichroism. A sixfold axis does not create three optically distinct directions + in terms of absorption; the gem still has only two principal vibration directions + (ordinary and extraordinary). Additionally, RI 1.620–1.640 does not match + hexagonal beryl (RI 1.577–1.583). A student might confuse "six sides" with + "three absorption directions," but crystallographic symmetry and optical + absorption are related differently: the number of fold-axes does not directly + equal the number of absorption colours. +rationaleCorrect: | + Tourmaline is trigonal (point group 3m), uniaxial negative, RI 1.620–1.640, + birefringence up to 0.020 — the largest of any common gem. The uniaxial conoscope + figure (one isogyres cross) confirms trigonal or hexagonal or tetragonal system. + The RI range confirms tourmaline specifically. A critical vocabulary point: + uniaxial gems show dichroism (two absorption directions: O-ray and E-ray). Only + biaxial gems (orthorhombic, monoclinic, triclinic) show true trichroism along + three independent vibration directions. If a student says "trichroism" for + tourmaline, the gemmologically correct term is "dichroism." Tourmaline's + dichroism is exceptionally strong — sometimes the two colours are radically + different — which may mislead a student into thinking three colours exist. +difficulty: 3 +category: fundamentals +conceptTags: + - crystal-system + - trigonal-system + - uniaxial + - pleochroism + - tourmaline + - optic-character +sourceArticle: fundamentals/crystal-systems +examRelevance: FGA-foundation +confusionPairs: + - crystal-system-002 + - crystal-system-003 +similarTo: + - crystal-system-002 + - crystal-system-005 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 17–30." diff --git a/src/content/questions/fundamentals/crystal-system-002.yaml b/src/content/questions/fundamentals/crystal-system-002.yaml new file mode 100644 index 0000000..e8ce41a --- /dev/null +++ b/src/content/questions/fundamentals/crystal-system-002.yaml @@ -0,0 +1,73 @@ +id: crystal-system-002 +stem: | + A student studying the 32 crystallographic point groups notes that corundum belongs + to point group 3-bar-m (written 3̄m in Hermann-Mauguin notation). The student is + asked why corundum is neither optically active nor piezoelectric, and what the + "3̄" symbol in its point group indicates about its symmetry. Which answer is correct? +type: mcq +options: + - text: "The 3̄ indicates a rotoinversion axis (an inversion centre combined with a threefold rotation), giving corundum a centre of symmetry; a centre of symmetry prevents optical activity and piezoelectricity" + isCorrect: true + rationale: | + The Hermann-Mauguin symbol 3̄ (three-bar) denotes a rotoinversion axis: a + threefold rotation combined with an inversion through the centre. The 3̄ axis + contains a centre of inversion (as all even-fold rotoinversion axes and 3̄ + specifically do). A centre of inversion (centrosymmetric point group) eliminates + the possibility of piezoelectricity (which requires no centre of inversion) and + optical activity (which requires an enantiomorphic, non-centrosymmetric class). + Corundum in point group 3̄m is centrosymmetric — confirmed by its lack of + piezoelectric or optical-activity effects. + - text: "The 3̄ indicates a sixfold screw axis, placing corundum in the hexagonal system and making it optically active like quartz" + isCorrect: false + rationale: | + A sixfold screw axis is denoted 6₁, 6₂, etc. — these are space-group notations, + not point-group notations. The point group 3̄m belongs to the trigonal system, + not hexagonal. Quartz belongs to point group 32 (trigonal), which is enantiomorphic + and therefore optically active. Corundum (3̄m) has a centre of inversion that + quartz (32) lacks — this is precisely why corundum is not optically active while + quartz is. Confusing screw axes (space group) with rotoinversion axes (point group) + is a common error at Foundation level. + - text: "The 3̄ means corundum has three mirror planes arranged around the c-axis, and mirror planes cause the cancellation of piezoelectric effects" + isCorrect: false + rationale: | + The "m" in 3̄m represents mirror planes (three of them, parallel to the c-axis), + not the "3̄" symbol. The 3̄ is the rotoinversion axis — a separate symmetry element. + While mirror planes do prevent certain piezoelectric modes, it is the inversion + centre (contained in the 3̄ axis) that is the primary reason corundum lacks + piezoelectricity and optical activity. Misreading 3̄ as "three mirror planes" is + a notation error: in H-M notation the bar above a number specifically means + rotoinversion, which is distinct from a mirror plane (written m). +rationaleCorrect: | + Point group 3̄m (trigonal) contains a 3̄ rotoinversion axis, which inherently includes + a centre of inversion. Centrosymmetric point groups cannot be piezoelectric or + optically active: piezoelectricity requires that the positive and negative charge + centres can be separated by stress, which requires no inversion centre; optical + activity requires an enantiomorphic class, also absent when a centre of inversion + exists. Compare to quartz (point group 32, trigonal): 32 has no inversion centre, + is enantiomorphic, and therefore IS optically active (exists in left- and right-handed + forms) and piezoelectric. Tourmaline (3m) also lacks an inversion centre (polar axis) + and IS piezoelectric. Corundum (3̄m) has the inversion centre and shows neither. +difficulty: 4 +category: fundamentals +conceptTags: + - point-group + - symmetry + - crystal-system + - trigonal-system + - corundum +sourceArticle: fundamentals/crystal-systems +examRelevance: FGA-foundation +confusionPairs: + - crystal-system-001 + - crystal-system-003 +similarTo: + - crystal-system-003 + - crystal-system-004 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: schwarzenbach-2003 + citation: "Schwarzenbach, D. (2003). Journal of Applied Crystallography, 36(1), 1–3. DOI: 10.1107/s0021889803014778" + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 17–30." diff --git a/src/content/questions/fundamentals/crystal-system-003.yaml b/src/content/questions/fundamentals/crystal-system-003.yaml new file mode 100644 index 0000000..7e84682 --- /dev/null +++ b/src/content/questions/fundamentals/crystal-system-003.yaml @@ -0,0 +1,75 @@ +id: crystal-system-003 +stem: | + A gemmology student is trying to determine whether an unknown gem belongs to the + orthorhombic or monoclinic crystal system. Under the polariscope, the stone shows + a biaxial interference figure. On the refractometer, the student records three + distinct RI values. The stone displays distinct trichroism. Which crystal system + property eliminates monoclinic and confirms orthorhombic as the more likely answer, + given the additional observation that all three crystallographic axes meet at exactly + 90 degrees? +type: mcq +options: + - text: "In orthorhombic crystals all three axes are mutually perpendicular (alpha = beta = gamma = 90°), whereas in monoclinic one of the inter-axial angles departs from 90° (beta ≠ 90°)" + isCorrect: true + rationale: | + Both orthorhombic and monoclinic systems are biaxial and show trichroism, so + optical properties alone cannot separate them in this scenario. The diagnostic + is the axial geometry: orthorhombic has a ≠ b ≠ c but all angles = 90°; monoclinic + has a ≠ b ≠ c with alpha = gamma = 90° but beta ≠ 90°. The student's observation + that all three axes meet at 90° confirms orthorhombic. In practice this is + determined from the crystal morphology or X-ray diffraction, not directly from + gemmological instruments — but the axial angle definition is fundamental + crystallography that the FGA examination tests. + - text: "Orthorhombic gems always show stronger trichroism than monoclinic gems, and the strength of trichroism confirms the system" + isCorrect: false + rationale: | + The intensity of pleochroism depends on the chromophore concentration and crystal + chemistry, not directly on the crystal system. Monoclinic gems (e.g. spodumene, + diopside) can show strong trichroism; orthorhombic gems (e.g. topaz) may show weak + trichroism. There is no rule that orthorhombic gems have systematically stronger + pleochroism. Both systems are biaxial and support trichroism; the degree of + trichroism is not a reliable crystal-system discriminator. The axial angle geometry + is the correct structural criterion. + - text: "Orthorhombic gems give a centred optic axis figure with a straight isogyre, while monoclinic gems always give a Bxa figure with curved isogyres" + isCorrect: false + rationale: | + The shape of isogyres on a conoscope figure depends on crystal orientation relative + to the optical section, not on whether the crystal is orthorhombic or monoclinic. + Both systems can produce Bxa, Bxo, or optic axis figures depending on the cut + orientation. Straight versus curved isogyres reflect 2V (the optic axial angle) — + a 2V of approximately 90° gives nearly straight isogyres in a Bxa figure, but this + occurs in both orthorhombic and monoclinic gems. Curved isogyres are related to + small 2V, again independent of the crystal system. +rationaleCorrect: | + Both orthorhombic and monoclinic systems are biaxial, show trichroism, and give similar + interference figures. The defining structural distinction is the inter-axial angles: + orthorhombic has all three angles at exactly 90° (a ≠ b ≠ c; alpha = beta = gamma = 90°); + monoclinic has two right angles but one oblique angle (alpha = gamma = 90°, beta ≠ 90°). + In gemmological practice, individual gem identification uses RI, SG, and spectroscopic + tests rather than direct axial angle measurement; but the crystal-system definition is + tested at FGA level as pure crystallographic knowledge. Common orthorhombic gems: + topaz, peridot, tanzanite, chrysoberyl. Common monoclinic gems: orthoclase, spodumene, + jadeite, diopside. +difficulty: 3 +category: fundamentals +conceptTags: + - crystal-system + - orthorhombic-system + - monoclinic-system + - biaxial + - symmetry + - pleochroism +sourceArticle: fundamentals/crystal-systems +examRelevance: FGA-foundation +confusionPairs: + - crystal-system-001 + - crystal-system-004 +similarTo: + - crystal-system-002 + - crystal-system-004 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 17–30." diff --git a/src/content/questions/fundamentals/crystal-system-004.yaml b/src/content/questions/fundamentals/crystal-system-004.yaml new file mode 100644 index 0000000..e7ac950 --- /dev/null +++ b/src/content/questions/fundamentals/crystal-system-004.yaml @@ -0,0 +1,76 @@ +id: crystal-system-004 +stem: | + A gemmologist examines a faceted gem under the polariscope and finds that it remains + uniformly dark throughout a full 360° rotation between crossed polars — showing no + lightening at any position. This result is confirmed on two different facets. The + refractometer gives a single constant shadow edge. Which crystal systems are consistent + with this observation, and what is the minimum additional test needed to narrow the + identification? +type: mcq +options: + - text: "Cubic system only, or amorphous material; the refractometer RI value and SG measurement together will distinguish cubic gems from glass or amorphous materials" + isCorrect: true + rationale: | + Uniform darkness on the polariscope throughout full rotation confirms isotropic + optical behaviour. Only two types of material are optically isotropic: (1) cubic + crystals, which have equal optical properties in all directions due to their highest + symmetry; and (2) amorphous materials (glass, natural glass, amber, opal) which + have no regular crystal structure. Both give a single constant shadow edge on the + refractometer. The RI value narrows the species; SG measurement confirms identity + by ruling out glass (low SG ~2.5) versus a specific cubic mineral. + - text: "Cubic and tetragonal systems; tetragonal gems can appear isotropic when viewed down the c-axis" + isCorrect: false + rationale: | + A tetragonal gem viewed exactly down its c-axis would appear isotropic on the + polariscope in that specific orientation. However, the question states the test + was confirmed on two different facets, making it very unlikely that both facets + happen to be cut perpendicular to the c-axis. Furthermore, a tetragonal gem would + give two shadow edges on the refractometer (a birefringence reading) in any + orientation that is not exactly down the optic axis — the refractometer reading + of a single constant edge on multiple facets strongly implies true isotropy, not + an accident of orientation. + - text: "Trigonal system only; the polariscope is unreliable for distinguishing trigonal from cubic" + isCorrect: false + rationale: | + Trigonal gems are uniaxial — they are optically anisotropic and will show lightening + on the polariscope when rotated, unless viewed exactly down the c-axis. The claim + that the polariscope is unreliable for distinguishing trigonal from cubic is + incorrect; the polariscope is precisely the instrument used to make this distinction. + A trigonal gem examined on multiple facets will show double refraction somewhere + unless every facet is coincidentally cut exactly perpendicular to the c-axis, + which is an extraordinary coincidence. The result consistent with trigonal would + be lightening at 45° intervals during rotation. +rationaleCorrect: | + Isotropic behaviour on the polariscope (uniformly dark at all rotation positions, + confirmed on multiple facets) is definitive for either a cubic crystal or an amorphous + material. No other crystal system can produce this result consistently across multiple + facets. The refractometer gives a single constant edge (Pattern I), consistent with + both. To distinguish cubic from amorphous: compare the RI value to known cubic gems + (diamond 2.417, spinel 1.718, garnet 1.73–1.89) and amorphous materials (glass + 1.44–1.70, amber ~1.54, opal 1.44–1.46). If the RI is diagnostic, SG confirms. + Anomalous double refraction (ADR) in some cubic gems (e.g. garnets) shows as + patchy rather than fully uniform behaviour — distinguished from true isotropy by + the irregular, strain-related pattern rather than uniform dark. +difficulty: 3 +category: fundamentals +conceptTags: + - crystal-system + - cubic-system + - isotropic + - optic-character + - specific-gravity + - refractive-index +sourceArticle: fundamentals/crystal-systems +examRelevance: FGA-foundation +confusionPairs: + - crystal-system-001 + - crystal-system-005 +similarTo: + - crystal-system-001 + - crystal-system-003 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 17–30, 54–60." diff --git a/src/content/questions/fundamentals/crystal-system-005.yaml b/src/content/questions/fundamentals/crystal-system-005.yaml new file mode 100644 index 0000000..7bb2e17 --- /dev/null +++ b/src/content/questions/fundamentals/crystal-system-005.yaml @@ -0,0 +1,69 @@ +id: crystal-system-005 +stem: | + A student is shown a rough crystal with clearly developed faces and asked to identify + its crystal system from the morphology alone. The crystal has three pairs of equivalent + pinacoid faces meeting at exactly 90° on all edges, but the three pairs of faces are + all different sizes (a ≠ b ≠ c axes are unequal in length). No other faces are present. + What crystal system does this morphology indicate? +type: mcq +options: + - text: "Orthorhombic; three unequal axes all at 90° is the defining geometry of orthorhombic" + isCorrect: true + rationale: | + The orthorhombic system is defined by three mutually perpendicular crystallographic + axes (all inter-axial angles = 90°) with all three axes of different lengths + (a ≠ b ≠ c). A crystal with three pairs of rectangular faces, all meeting at right + angles but of different sizes, perfectly expresses this geometry. The tetragonal + system also has all right angles but requires two axes of equal length (a = b ≠ c), + which would produce two identical pairs of faces and one different pair — not three + different pairs. Cubic requires all three axes equal. + - text: "Tetragonal; tetragonal crystals have three axes at 90° and commonly show different face sizes" + isCorrect: false + rationale: | + Tetragonal crystals have two equal horizontal axes and one unique vertical axis + (a = b ≠ c), with all three axes at 90°. This means the four faces parallel to + the a and b axes are equivalent, producing only two pairs of different face sizes — + not three pairs as described. If all three pinacoid pairs are different sizes, the + two horizontal axes cannot be equal, which rules out tetragonal. A student might + choose tetragonal because "three axes at 90°" matches both orthorhombic and + tetragonal, but the size criterion (a = b in tetragonal) is decisive. + - text: "Cubic; the 90° inter-axial angles and pairs of opposite faces confirm cubic symmetry" + isCorrect: false + rationale: | + Cubic crystals have all three axes equal in length (a = b = c) at 90°. This means + all six faces of a simple cubic form (the cube) are identical in size. If the three + face pairs are all different sizes (implying unequal axes), the crystal cannot be + cubic. A student might reach for cubic because 90° angles are present, but the + defining feature of cubic — equal axes — directly contradicts three different + face sizes. Cubic gem crystals (garnet octahedra, spinel octahedra) show faces + that are all equivalent by symmetry. +rationaleCorrect: | + The orthorhombic system is uniquely identified by its axial definition: three unequal + axes (a ≠ b ≠ c) that are all mutually perpendicular (alpha = beta = gamma = 90°). + The crystal habit described — three different-sized pinacoid pairs at all right angles — + perfectly expresses orthorhombic geometry. Gemmologically important orthorhombic gems: + topaz (perfect basal cleavage), peridot (olivine), tanzanite (zoisite), chrysoberyl + (alexandrite, cat's-eye), andalusite, danburite. All are biaxial and show true + trichroism. The distinction from tetragonal (a = b ≠ c) is the key: equal horizontal + axes in tetragonal force two of the three face-pair sizes to be the same. +difficulty: 2 +category: fundamentals +conceptTags: + - crystal-system + - orthorhombic-system + - crystal-habit + - symmetry +sourceArticle: fundamentals/crystal-systems +examRelevance: FGA-foundation +confusionPairs: + - crystal-system-003 + - crystal-system-006 +similarTo: + - crystal-system-003 + - crystal-system-004 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 17–30." diff --git a/src/content/questions/fundamentals/crystal-system-006.yaml b/src/content/questions/fundamentals/crystal-system-006.yaml new file mode 100644 index 0000000..99ed331 --- /dev/null +++ b/src/content/questions/fundamentals/crystal-system-006.yaml @@ -0,0 +1,76 @@ +id: crystal-system-006 +stem: | + A gemmologist is examining a rough corundum crystal that shows prominent flat, shiny + internal planes that could be either cleavage or parting. A client has described the + stone as having "perfect basal cleavage." The gemmologist knows this description is + incorrect. What is the correct explanation of what the client has observed, and how + does it differ from true cleavage? +type: mcq +options: + - text: "Corundum has no cleavage; the flat internal planes are parting along polysynthetic twin planes — rhombohedral {10-11} parting is the most prominent, arising from twinning, not from inherently weak bonds in the structure" + isCorrect: true + rationale: | + Corundum (Al2O3) has no true cleavage — this is a well-established gemmological + fact. The flat, reflective planes observed in corundum are parting: a tendency to + split along the planes of polysynthetic twinning. Two parting directions are known: + rhombohedral parting on {10-11} (the most prominent, arising from repeated twinning + on that plane) and basal parting on {0001}. Parting is distinguished from cleavage + by being contingent — only specimens with twinning show parting, and it does not + occur universally in all corundum specimens the way true cleavage would. + - text: "Corundum has one direction of imperfect cleavage parallel to the basal pinacoid {0001}, commonly called parting by mistake" + isCorrect: false + rationale: | + This answer incorrectly grants corundum a cleavage. Standard gemmological and + mineralogical references (Read, Hurlbut & Klein) consistently state that corundum + has no cleavage. What appears as "basal cleavage" is in fact basal parting arising + from twinning on the {0001} plane. Describing it as "imperfect cleavage commonly + called parting by mistake" reverses the relationship — it is actually parting that + is commonly mistaken for cleavage, not the other way around. The distinction + matters for durability assessment: cleavage is a universal weakness; parting only + weakens specimens with that specific twinning. + - text: "Corundum belongs to the trigonal system and all trigonal gems have one direction of rhombohedral cleavage related to the {10-11} planes" + isCorrect: false + rationale: | + Trigonal symmetry does not automatically confer rhombohedral cleavage. Calcite + (trigonal, 3̄m) has perfect rhombohedral cleavage on {10-11}, but corundum (also + trigonal, 3̄m, same point group) has no cleavage on those planes — it has parting. + The presence or absence of cleavage is determined by the specific bond strengths + in the crystal structure, not by the crystal system alone. Making a rule that all + trigonal gems have rhombohedral cleavage would incorrectly apply calcite's cleavage + to corundum. Quartz (trigonal) also lacks cleavage, showing conchoidal fracture. +rationaleCorrect: | + Corundum has no cleavage in any direction. The flat planes observed are rhombohedral + parting on {10-11} — caused by polysynthetic twinning — and less commonly basal + parting on {0001}. The key distinction from cleavage: cleavage is universal in every + crystal of a species (it reflects inherently weaker bond planes in the structure); + parting is contingent on twinning and not present in all specimens. This is one of the + most frequently tested corundum facts in FGA examinations. A common exam error states + "corundum has perfect basal cleavage" — this is incorrect. The iridescent flat planes + sometimes seen internally in faceted rubies and sapphires are parting planes, not + cleavage. Knowing this matters practically: a stone with parting is not universally + weakened in the way that a stone with perfect cleavage (like topaz or diamond) is. +difficulty: 3 +category: fundamentals +conceptTags: + - cleavage + - parting + - crystal-system + - trigonal-system + - corundum + - miller-indices +sourceArticle: fundamentals/crystal-systems +examRelevance: FGA-foundation +confusionPairs: + - crystal-system-007 + - crystal-system-008 +similarTo: + - crystal-system-005 + - crystal-system-007 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 25–28." + - id: pignatelli-2024 + citation: "Pignatelli, I. et al. (2024). Journal of Gemmology. DOI: 10.1007/s00710-024-00858-1" diff --git a/src/content/questions/fundamentals/crystal-system-007.yaml b/src/content/questions/fundamentals/crystal-system-007.yaml new file mode 100644 index 0000000..8014ba0 --- /dev/null +++ b/src/content/questions/fundamentals/crystal-system-007.yaml @@ -0,0 +1,77 @@ +id: crystal-system-007 +stem: | + A student compares the cleavage of two gems: topaz and diamond. Topaz has perfect + basal cleavage on {001}; diamond has perfect octahedral cleavage on {111}. Both + have rated "perfect" quality. A jewellery client asks why topaz is considered more + fragile in a ring setting than diamond, if both have perfect cleavage. What is the + most accurate gemmological explanation? +type: mcq +options: + - text: "Topaz has only one cleavage direction (basal {001}), producing a single easily-exploited weakness perpendicular to the table facet of a standard cut; diamond's {111} cleavage operates in four equivalent directions so no single setting orientation is particularly vulnerable" + isCorrect: true + rationale: | + Topaz has perfect cleavage in only one direction — the basal pinacoid {001}, + perpendicular to the c-axis. In a standard oval or round brilliant cut, the table + facet is typically cut approximately perpendicular to the c-axis, meaning the + cleavage plane runs nearly parallel to the girdle — a position where blows from + above or setting pressure directly exploits the cleavage. Diamond has four {111} + cleavage directions arranged as octahedral faces, distributing vulnerability more + evenly. Hardness (diamond 10, topaz 8) also reduces scratch damage, but the + single-direction cleavage is the key durability issue for topaz in daily wear. + - text: "Topaz has lower hardness than diamond (8 vs 10), and hardness directly determines cleavage fragility" + isCorrect: false + rationale: | + Hardness (resistance to scratching) and cleavage (tendency to split along bond + planes under impact) are independent properties. Hardness does not determine + cleavage fragility — these are different aspects of durability. A gem with hardness + 10 but perfect cleavage (diamond) can still split cleanly from a sharp blow; + a gem with hardness 6.5 but no cleavage (jadeite) resists fracture well. + The question specifically asks about cleavage fragility, not scratch resistance. + Conflating hardness with cleavage fragility is a common student error that + this question is designed to address. + - text: "Diamond does not have true cleavage — what appears as cleavage in diamond is actually fracture, making it more impact-resistant than topaz" + isCorrect: false + rationale: | + Diamond absolutely has perfect octahedral cleavage on {111} — this is one of the + most established facts in gemmology. Diamond cleavage has been used in the gem + trade for centuries to split rough diamonds before faceting. What diamond lacks + is easy cleavage in everyday ring-wear orientations (because four directions + distribute risk), but it is factually incorrect to say diamond does not have + cleavage. Misidentifying diamond's flat fracture surfaces as "fracture rather + than cleavage" contradicts the mineralogical record and would be marked wrong + in an FGA examination. +rationaleCorrect: | + Both topaz and diamond have "perfect" cleavage, but the geometry differs critically. + Topaz has one cleavage direction: {001} basal, a single plane running across the + entire crystal. In a ring stone cut with the table perpendicular to c, the cleavage + plane is roughly horizontal — any sharp blow from above drives directly along the + cleavage. Diamond has four equivalent {111} cleavage directions; no common ring + orientation is perpendicular to all four simultaneously, so cleavage is harder to + exploit accidentally. Additionally, topaz hardness 8 (vs. diamond 10) means + topaz surfaces wear more quickly. For ring settings: topaz should be set in + protective bezels; diamond in prong settings is acceptable given its superior + hardness and distributed cleavage geometry. +difficulty: 3 +category: fundamentals +conceptTags: + - cleavage + - hardness + - topaz + - diamond + - crystal-system + - orthorhombic-system + - cubic-system +sourceArticle: fundamentals/physical-properties +examRelevance: FGA-foundation +confusionPairs: + - crystal-system-006 + - crystal-system-008 +similarTo: + - crystal-system-006 + - crystal-system-008 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 25–28." diff --git a/src/content/questions/fundamentals/crystal-system-008.yaml b/src/content/questions/fundamentals/crystal-system-008.yaml new file mode 100644 index 0000000..75d5eee --- /dev/null +++ b/src/content/questions/fundamentals/crystal-system-008.yaml @@ -0,0 +1,75 @@ +id: crystal-system-008 +stem: | + During a practical session, a student examines a rough zircon crystal. The crystal + shows a strongly developed tetragonal prism habit with four equivalent rectangular + side faces and two square terminal faces. On the refractometer, the student records + RI values of 1.931 and 1.984 — a birefringence of 0.053. On the polariscope, it + lights up strongly. Which crystal system does zircon belong to, and which optic + character is consistent with the tetragonal system? +type: mcq +options: + - text: "Tetragonal; uniaxial positive — tetragonal gems are always uniaxial, and high zircon is uniaxial positive with the very high birefringence of 0.053" + isCorrect: true + rationale: | + Zircon (ZrSiO4) belongs to the tetragonal system (point group 4/mmm), which + is always uniaxial. The tetragonal crystal habit — four-fold symmetry in the + horizontal plane, one four-fold axis — is consistent with the prism+terminal + habit described. Tetragonal gems are uniaxial; zircon specifically is uniaxial + positive (epsilon > omega). High zircon's birefringence of 0.053 is the largest + of common gem-quality stones (exceeds tourmaline at ~0.020, exceeds calcite's + 0.172 is not a gem), making the strongly lit polariscope response expected. + - text: "Hexagonal; hexagonal and tetragonal crystals look similar and both give uniaxial readings" + isCorrect: false + rationale: | + Hexagonal and tetragonal systems are different. Hexagonal crystals show six-fold + symmetry — six equivalent side faces on the prism — while tetragonal crystals + show four-fold symmetry, as in the described crystal with four equivalent side + faces. The crystal habit described (four side faces, square terminals) is + tetragonal, not hexagonal. Both are uniaxial, but the crystal habit and the + number of equivalent faces distinguish them. Gem examples: hexagonal = beryl + (emerald, aquamarine); tetragonal = zircon, vesuvianite. + - text: "Orthorhombic; the high birefringence confirms biaxial character, not uniaxial" + isCorrect: false + rationale: | + High birefringence does not indicate biaxial character. Birefringence (the + numerical difference between maximum and minimum RI) can be large in both + uniaxial and biaxial gems. Zircon's 0.053 is large but it is a uniaxial stone + measured between omega and epsilon. The crystal habit (four-fold prism) and the + tetragonal system both confirm uniaxial optic character. An orthorhombic gem + with similar birefringence would show a biaxial interference figure and three + distinct RI values — zircon gives two RI values on the refractometer and a + uniaxial figure on the conoscope. +rationaleCorrect: | + Zircon is tetragonal (4/mmm), uniaxial positive, with one of the highest birefringences + of any gem — high zircon 0.053, so strong that doubling of back facets is visible to + the naked eye through the table. Tetragonal crystals always have one four-fold axis + and are uniaxial. The RI of high zircon (1.931–1.984) is above the standard + refractometer range, but the values quoted here represent a direct instrument reading + on a low or medium metamict sample. High zircon RI can reach 1.984 on alpha and 1.931 + for omega — read on a faceted stone with the standard technique this is Pattern II or + III with very widely spaced edges. Zircon is diagnostically identified by: tetragonal + habit, high birefringence, high SG (4.69), adamantine lustre, and UV-visible absorption + spectrum with characteristic lines at 653 nm. +difficulty: 2 +category: fundamentals +conceptTags: + - crystal-system + - tetragonal-system + - uniaxial + - birefringence + - zircon + - optic-character +sourceArticle: fundamentals/crystal-systems +examRelevance: FGA-foundation +confusionPairs: + - crystal-system-005 + - crystal-system-004 +similarTo: + - crystal-system-005 + - crystal-system-006 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 17–30, 182–185." diff --git a/src/content/questions/fundamentals/crystal-system-009.yaml b/src/content/questions/fundamentals/crystal-system-009.yaml new file mode 100644 index 0000000..b74bde9 --- /dev/null +++ b/src/content/questions/fundamentals/crystal-system-009.yaml @@ -0,0 +1,78 @@ +id: crystal-system-009 +stem: | + A gemmologist reads the following four-index Miller index notation on a laboratory + report: {10-11}. The report relates this to a parting direction in corundum. The + gemmologist's assistant asks why a four-index notation is used instead of the simpler + three-index {hkl} notation. What is the correct explanation, and what crystal systems + require four-index (Bravais-Miller) notation? +type: mcq +options: + - text: "Four-index notation {hkil} is required for trigonal and hexagonal systems because they have three equivalent horizontal axes at 120° apart; the redundant index i = −(h+k) makes the symmetry equivalence of faces explicit" + isCorrect: true + rationale: | + Trigonal and hexagonal crystal systems have three equal horizontal axes (a1, a2, + a3) arranged at 120° to each other, plus a unique vertical c-axis. Three-index + notation cannot capture the six-fold (or three-fold) equivalence of prism faces + because it does not include the third horizontal axis. By adding the redundant + index i, where i = −(h+k), all equivalent prism faces can be recognised by + inspection: the indices {10-10}, {01-10}, {-1100}, etc. are obviously related. + The closure relation i = −(h+k) ensures the third horizontal axis intercept is + not an independent variable. Corundum {10-11} is the rhombohedral parting and + twin plane. + - text: "Four-index notation is required whenever Miller indices include a negative value, regardless of crystal system, to keep track of the signs on all four axes" + isCorrect: false + rationale: | + Negative Miller index values (the bar notation) appear in all crystal systems + whenever a face intercepts an axis in the negative direction. Three-index notation + routinely uses negative values: {-110}, {10-1}, etc. are standard three-index + Miller indices for cubic, tetragonal, orthorhombic, monoclinic, and triclinic + systems. The need for four indices has nothing to do with the sign of the indices; + it is purely about the symmetry of the hexagonal/trigonal system having three + equivalent horizontal axes. A student who chooses this answer has conflated + bar notation (negative intercepts) with the reason for using four indices. + - text: "Four-index notation is used for all high-symmetry crystal systems (cubic, hexagonal, tetragonal) to increase precision in describing crystal faces" + isCorrect: false + rationale: | + Cubic and tetragonal systems do not use four-index notation. The cubic system + uses standard three-index {hkl} (e.g. {111} for the octahedron, {100} for + the cube). The tetragonal system also uses three-index notation. Four-index + (Bravais-Miller) notation is exclusively for hexagonal and trigonal systems + because of their three-axis horizontal symmetry. Using four indices for cubic + or tetragonal would be unconventional and would not add information because + those systems do not have the three-fold horizontal axis equivalence that + four-index notation exploits. +rationaleCorrect: | + The Bravais-Miller four-index {hkil} notation is required specifically for trigonal + and hexagonal crystal systems because these have three crystallographically equivalent + horizontal axes (a1, a2, a3) at 120° to each other, plus the unique c-axis. The + closure relation i = −(h+k) ensures that i is not an independent variable; it is a + derived index that makes the three-fold or six-fold equivalence of faces visible + by inspection. For corundum {10-11}: i = −(1+0) = −1, confirming h+k+i = 0. + The rhombohedral {10-11} plane is the parting and twin plane of corundum. + The examination tests whether students can explain WHY four indices — not just + that four indices are used. +difficulty: 4 +category: fundamentals +conceptTags: + - miller-indices + - crystal-system + - trigonal-system + - hexagonal-system + - corundum + - parting +sourceArticle: fundamentals/crystal-systems +examRelevance: FGA-foundation +confusionPairs: + - crystal-system-006 + - crystal-system-010 +similarTo: + - crystal-system-006 + - crystal-system-010 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: schwarzenbach-2003 + citation: "Schwarzenbach, D. (2003). Journal of Applied Crystallography, 36(1), 1–3. DOI: 10.1107/s0021889803014778" + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 17–30." diff --git a/src/content/questions/fundamentals/crystal-system-010.yaml b/src/content/questions/fundamentals/crystal-system-010.yaml new file mode 100644 index 0000000..4e71451 --- /dev/null +++ b/src/content/questions/fundamentals/crystal-system-010.yaml @@ -0,0 +1,74 @@ +id: crystal-system-010 +stem: | + A student is asked to sort five gem species into crystal systems and match them to + their optic character. The species are: almandine garnet, blue sapphire, aquamarine, + tanzanite, and jadeite. Which combination below correctly pairs all five with their + crystal system and optic character? +type: mcq +options: + - text: "Almandine: cubic/isotropic; blue sapphire: trigonal/uniaxial negative; aquamarine: hexagonal/uniaxial negative; tanzanite: orthorhombic/biaxial positive; jadeite: monoclinic/biaxial" + isCorrect: true + rationale: | + All five assignments are correct: almandine garnet is cubic (isometric) and + isotropic (single RI, no birefringence); corundum (sapphire) is trigonal, uniaxial + negative; beryl (aquamarine) is hexagonal, uniaxial negative; tanzanite (zoisite) + is orthorhombic, biaxial positive; jadeite is monoclinic, biaxial. The two + uniaxial negative species (corundum and beryl) are a common confusion point + because both are uniaxial negative but belong to different uniaxial systems + (trigonal vs. hexagonal). + - text: "Almandine: cubic/isotropic; blue sapphire: hexagonal/uniaxial positive; aquamarine: trigonal/uniaxial negative; tanzanite: monoclinic/biaxial; jadeite: orthorhombic/biaxial" + isCorrect: false + rationale: | + Two species are swapped and one optic sign is wrong. Corundum (sapphire) belongs + to the trigonal system, not hexagonal — it shares point group 3̄m with other + trigonal minerals. Beryl (aquamarine) is hexagonal (point group 6/mmm). Both are + uniaxial negative; it is the crystal system that differs. Tanzanite (zoisite) is + orthorhombic, not monoclinic; jadeite is monoclinic, not orthorhombic. This + distractor tests the specific system-vs-sign combinations that students frequently + mix up, particularly the trigonal/hexagonal pair for corundum and beryl. + - text: "Almandine: trigonal/uniaxial; blue sapphire: cubic/isotropic; aquamarine: hexagonal/uniaxial negative; tanzanite: orthorhombic/biaxial; jadeite: monoclinic/biaxial" + isCorrect: false + rationale: | + Almandine garnet is cubic (isometric), not trigonal, and is isotropic — not + uniaxial. Blue sapphire (corundum) is trigonal, uniaxial negative — not cubic. + Swapping garnet and corundum is a particularly meaningful error: both can be red + or blue in appropriate varieties, and the species discrimination depends critically + on the correct system assignment. A student who confuses garnet's cubic isotropy + with corundum's trigonal uniaxial character would fail to distinguish them on + the polariscope, which is a practical identification failure. +rationaleCorrect: | + Memorising the crystal systems and optic characters of the major gem species is a + Foundation-level requirement. The key pairings to anchor: (1) all garnets — cubic, + isotropic (with anomalous double refraction); (2) corundum (ruby, sapphire) — trigonal, + uniaxial negative; (3) beryl (emerald, aquamarine) — hexagonal, uniaxial negative; + (4) tanzanite/zoisite, topaz, peridot — orthorhombic, biaxial; (5) jadeite, spodumene, + orthoclase — monoclinic, biaxial. The two common errors are: confusing trigonal with + hexagonal for corundum/beryl (they differ in system but share uniaxial negative), and + placing garnets in trigonal rather than cubic. +difficulty: 2 +category: fundamentals +conceptTags: + - crystal-system + - optic-character + - cubic-system + - trigonal-system + - hexagonal-system + - orthorhombic-system + - monoclinic-system + - isotropic + - uniaxial + - biaxial +sourceArticle: fundamentals/crystal-systems +examRelevance: FGA-foundation +confusionPairs: + - crystal-system-004 + - crystal-system-001 +similarTo: + - crystal-system-004 + - crystal-system-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 17–30." From 323a271bbb6dba3c9bb2d2ded39d8e6b0784df6a Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:45:02 +0700 Subject: [PATCH 12/16] feat(study-content): add 10 fundamentals questions on RI/SG/hardness near-miss discrimination Covers near-miss property matching for fluorite vs topaz, diamond vs quartz, jadeite vs peridot, spinel calibration values, orange tourmaline vs peridot vs spessartine, blue spinel vs blue sapphire, green beryl vs tourmaline, kyanite directional hardness, nephrite vs jadeite, and moonstone adularescence discrimination. --- .../questions/fundamentals/phys-prop-001.yaml | 72 +++++++++++++++++ .../questions/fundamentals/phys-prop-002.yaml | 74 ++++++++++++++++++ .../questions/fundamentals/phys-prop-003.yaml | 76 ++++++++++++++++++ .../questions/fundamentals/phys-prop-004.yaml | 71 +++++++++++++++++ .../questions/fundamentals/phys-prop-005.yaml | 73 +++++++++++++++++ .../questions/fundamentals/phys-prop-006.yaml | 75 ++++++++++++++++++ .../questions/fundamentals/phys-prop-007.yaml | 72 +++++++++++++++++ .../questions/fundamentals/phys-prop-008.yaml | 75 ++++++++++++++++++ .../questions/fundamentals/phys-prop-009.yaml | 72 +++++++++++++++++ .../questions/fundamentals/phys-prop-010.yaml | 78 +++++++++++++++++++ 10 files changed, 738 insertions(+) create mode 100644 src/content/questions/fundamentals/phys-prop-001.yaml create mode 100644 src/content/questions/fundamentals/phys-prop-002.yaml create mode 100644 src/content/questions/fundamentals/phys-prop-003.yaml create mode 100644 src/content/questions/fundamentals/phys-prop-004.yaml create mode 100644 src/content/questions/fundamentals/phys-prop-005.yaml create mode 100644 src/content/questions/fundamentals/phys-prop-006.yaml create mode 100644 src/content/questions/fundamentals/phys-prop-007.yaml create mode 100644 src/content/questions/fundamentals/phys-prop-008.yaml create mode 100644 src/content/questions/fundamentals/phys-prop-009.yaml create mode 100644 src/content/questions/fundamentals/phys-prop-010.yaml diff --git a/src/content/questions/fundamentals/phys-prop-001.yaml b/src/content/questions/fundamentals/phys-prop-001.yaml new file mode 100644 index 0000000..f3c3d6b --- /dev/null +++ b/src/content/questions/fundamentals/phys-prop-001.yaml @@ -0,0 +1,72 @@ +id: phys-prop-001 +stem: | + A gemmologist performs hydrostatic weighing on a colourless faceted stone. The stone + weighs 0.52 g in air and 0.37 g when suspended in water. Calculated SG = 0.52 / + (0.52 − 0.37) = 3.47. The refractometer gives a single constant shadow edge at + approximately 1.434. No birefringence is measurable. Which gem is the most likely + identification, and which near-miss species can be eliminated by the SG result? +type: mcq +options: + - text: "Fluorite (SG 3.18, RI 1.434, isotropic); the SG of 3.47 is somewhat high for pure fluorite but quartz (SG 2.65) and glass (SG ~2.5) are firmly eliminated" + isCorrect: false + rationale: | + Fluorite has RI 1.434 (a classic calibration value) and is isotropic (cubic), + but its SG is typically 3.18, not 3.47. An SG of 3.47 is too high for fluorite + by a significant margin. While this answer correctly identifies the RI and + isotropic character as matching fluorite, the SG discrepancy (0.29 units above + fluorite's typical value) should prompt further investigation rather than + confirming fluorite. Inclusions of denser minerals could raise SG somewhat, but + a 9% elevation warrants considering other candidates. + - text: "Topaz (SG 3.49–3.57, RI 1.619–1.627, biaxial positive); the SG fits but the RI of 1.434 is far below topaz's range" + isCorrect: false + rationale: | + Topaz does have an SG in the 3.49–3.57 range, which is close to the measured + 3.47. However, topaz RI ranges from 1.619 to 1.627 — far above the measured + 1.434. More importantly, topaz is biaxial and would show birefringence on the + refractometer. An RI of 1.434 is unambiguously too low for topaz regardless of SG. + This distractor is designed to test whether students can correctly apply two + properties simultaneously rather than matching on one property while ignoring + the other. + - text: "Fluorite (SG 3.18, RI 1.434, isotropic); the SG discrepancy suggests the stone may contain dense inclusions, and the RI of 1.434 with single constant edge confirms isotropic fluorite pending verification" + isCorrect: true + rationale: | + Fluorite is cubic (isotropic), RI exactly 1.434 — one of the most precise + reference values in gemmology, used for refractometer calibration. The SG of + 3.47 is above the expected 3.18, suggesting possible inclusions of denser + minerals (fluorite commonly hosts calcite, pyrite, or other minerals). The + combination of RI 1.434, isotropic behaviour, and a refractometer value used + as a calibration standard points strongly to fluorite, with the SG anomaly + warranting a note about potential inclusions. No other common gem has RI 1.434 + and isotropic character. +rationaleCorrect: | + Fluorite (CaF2) is the reference standard for RI 1.434 and is cubic (isotropic), + giving Pattern I on the refractometer. Its typical SG is 3.18. The measured 3.47 + is significantly above this — likely due to dense mineral inclusions, which are + extremely common in fluorite (pyrite, calcite, galena inclusions all raise SG). + A clean fluorite crystal would yield SG 3.18, but natural specimens frequently + contain inclusions that elevate the measured value. The correct procedure is to + report the RI/optical findings as consistent with fluorite and note the elevated + SG as anomalous, recommending microscopic examination for inclusions. The key + lesson: always report both properties and flag anomalies rather than forcing + a match on one property while ignoring discrepancies in another. +difficulty: 3 +category: fundamentals +conceptTags: + - specific-gravity + - refractive-index + - isotropic + - hardness +sourceArticle: fundamentals/physical-properties +examRelevance: FGA-foundation +confusionPairs: + - phys-prop-002 + - phys-prop-003 +similarTo: + - phys-prop-002 + - phys-prop-004 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 29–43." diff --git a/src/content/questions/fundamentals/phys-prop-002.yaml b/src/content/questions/fundamentals/phys-prop-002.yaml new file mode 100644 index 0000000..618b168 --- /dev/null +++ b/src/content/questions/fundamentals/phys-prop-002.yaml @@ -0,0 +1,74 @@ +id: phys-prop-002 +stem: | + A pale yellow oval brilliant-cut stone is submitted to a gemmology laboratory. + Testing reveals: RI 1.544 (single constant shadow edge throughout rotation), SG 2.65, + hardness approximately 7 (scratches feldspar easily), vitreous lustre, conchoidal + fracture. A client suspects this is a yellow diamond. Which combination of properties + definitively rules out diamond and points to the correct identity? +type: mcq +options: + - text: "Diamond has RI 2.417 (above refractometer range), SG 3.52, and hardness 10 — all three are incompatible with the measured values; the stone is quartz (citrine or heat-treated amethyst), RI 1.544, SG 2.65, hardness 7" + isCorrect: true + rationale: | + Each measured property eliminates diamond decisively: RI 1.544 is far below + diamond's 2.417 (diamond gives no shadow edge on the standard refractometer); + SG 2.65 is less than half of diamond's 3.52; hardness 7 is three full Mohs units + below diamond's 10. The combination of RI 1.544, SG 2.65, and hardness 7 with + vitreous lustre and conchoidal fracture is textbook quartz. The pale yellow colour + suggests citrine (natural or heat-treated amethyst), which is a common diamond + simulant in low-end jewellery. + - text: "Diamond is isotropic and has a single constant shadow edge like the tested stone; only the SG and hardness eliminate it" + isCorrect: false + rationale: | + While it is true that diamond is isotropic (cubic) and would theoretically give + a single constant shadow edge, diamond's RI of 2.417 places it above the + refractometer's contact-liquid limit (~1.79). Diamond gives NO shadow edge on the + standard refractometer — the scale appears uniformly bright. The tested stone gives + a shadow edge at 1.544, which is unambiguously a stone with RI 1.544, not diamond. + The statement "diamond has a single constant shadow edge" is technically true only + for refractometers with higher-RI contact liquids; on standard instruments, diamond + produces no reading at all. + - text: "The vitreous lustre eliminates diamond, which has adamantine lustre; the other properties are consistent with diamond simulants including some glass" + isCorrect: false + rationale: | + Lustre alone does not eliminate diamond — it is a supporting observation, not a + decisive one, because lustre is affected by surface quality and polish. More + importantly, glass has RI typically 1.44–1.70, SG approximately 2.5, and + conchoidal fracture — overlapping somewhat with the test results. However, + glass is an amorphous material, not a crystal; it would also show isotropic + behaviour. The RI, SG, and hardness all point definitively to quartz rather + than glass: SG 2.65 is specific to quartz, not glass. Hardness 7 eliminates + glass (hardness approximately 5–6) as well as diamond. +rationaleCorrect: | + Diamond elimination rests on three simultaneous mismatches: (1) RI 1.544 vs diamond + 2.417 — a difference of 0.873, enormous by gemmological standards; (2) SG 2.65 vs + diamond 3.52 — nearly 1.0 unit difference; (3) hardness 7 vs diamond 10 — the maximum + difference on the Mohs scale. Any single one of these would eliminate diamond; all three + together with conchoidal fracture and vitreous lustre confirm quartz. Citrine is the + most common yellow quartz gem; it can be natural (trace iron) or heat-treated amethyst. + It is an important diamond simulant in inexpensive jewellery and a common misidentification + by untrained buyers. The three-property elimination protocol is the correct approach: + never rely on colour alone. +difficulty: 2 +category: fundamentals +conceptTags: + - refractive-index + - specific-gravity + - hardness + - diamond + - quartz + - isotropic +sourceArticle: fundamentals/physical-properties +examRelevance: FGA-foundation +confusionPairs: + - phys-prop-001 + - phys-prop-003 +similarTo: + - phys-prop-001 + - phys-prop-004 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 29–43." diff --git a/src/content/questions/fundamentals/phys-prop-003.yaml b/src/content/questions/fundamentals/phys-prop-003.yaml new file mode 100644 index 0000000..1f6a399 --- /dev/null +++ b/src/content/questions/fundamentals/phys-prop-003.yaml @@ -0,0 +1,76 @@ +id: phys-prop-003 +stem: | + A gemmologist is testing a green oval cabochon. Hydrostatic SG = 3.34. The spot + method on the refractometer gives an approximate reading near 1.66. The stone has + a vitreous to somewhat greasy lustre, no fluorescence under UV, and shows an + uneven fracture. A colleague suggests the stone could be either jadeite or peridot. + Which single measurement most efficiently separates these two, and what values + would confirm each? +type: mcq +options: + - text: "Jadeite SG is 3.25–3.35, while peridot (olivine) SG is 3.28–3.48; the spot RI is the better discriminator — jadeite RI approximately 1.65–1.67, peridot RI 1.654–1.690 — but both overlap so SG plus RI together give highest confidence" + isCorrect: false + rationale: | + This answer correctly notes the overlap in both SG and RI ranges for jadeite and + peridot, making it a partially correct observation. However, the question asks + for the MOST efficient single measurement to separate them. The key discriminator + is not one property in isolation but the combination — and the answer underestimates + the value of spectroscopic testing (peridot's characteristic absorption spectrum + with iron bands is highly diagnostic) or the crystal form under the microscope + (jadeite is polycrystalline with interlocking grain texture; peridot is single + crystal with lily-pad inclusions). Accepting this as correct would reward a + response that hedges rather than identifies the best single test. + - text: "SG is the most efficient single discriminator for this pair: jadeite peaks at 3.25–3.35 (mean ~3.34), peridot ranges 3.28–3.48 with a typical mean near 3.34 too — they overlap significantly, so the spectroscope showing peridot's iron absorption at 493 nm is the most efficient single definitive test" + isCorrect: true + rationale: | + Jadeite SG 3.25–3.35 and peridot SG 3.28–3.48 overlap substantially, and the + measured SG of 3.34 falls in both ranges. Similarly, spot RI near 1.66 falls in + both species' ranges. The spectroscope provides a definitive single test: peridot + shows a characteristic iron absorption spectrum with bands at approximately 493 nm + and related features in the blue-green; jadeite lacks this spectrum. Additionally, + peridot's single-crystal nature means it will show birefringence (which a cabochon + spot reading may approximate), whereas jadeite's polycrystalline aggregate produces + no birefringence. The most efficient single test is the spectroscope. + - text: "Hardness testing: jadeite has hardness 6.5–7 and peridot 6.5–7, so hardness testing is the fastest single test and will separate them" + isCorrect: false + rationale: | + Both jadeite and peridot have virtually identical Mohs hardness ranges (jadeite + 6.5–7; peridot 6.5–7). Hardness testing would not separate them and is also a + damaging test — inappropriate for a finished cabochon submitted by a client. + Recommending hardness testing on a finished gem is poor laboratory practice when + non-destructive optical and spectroscopic methods are available. A student who + chooses this answer has either confused the hardness values or forgotten that + hardness testing scratches the surface of the stone. +rationaleCorrect: | + The jadeite-versus-peridot discrimination is one of the classic "overlapping SG and RI" + problems in gemmology. Both cluster around SG 3.3 and RI 1.65–1.67 in spot readings. + The decisive non-destructive tests are: (1) spectroscope — peridot shows iron absorption + bands (particularly ~493 nm); jadeite shows a different pattern or may be featureless in + the visible range; (2) microscopy — peridot is single crystal with characteristic lily-pad + chromite inclusions; jadeite is polycrystalline with an interlocking "sugary" texture; + (3) birefringence — peridot (biaxial positive, birefringence 0.036) will show strong + birefringence effects on the refractometer on a polished facet; jadeite as a polycrystalline + aggregate shows no net birefringence. In practice, a combination of spectroscope and + microscopy resolves the case definitively. +difficulty: 4 +category: fundamentals +conceptTags: + - specific-gravity + - refractive-index + - hardness + - peridot + - biaxial +sourceArticle: fundamentals/physical-properties +examRelevance: FGA-foundation +confusionPairs: + - phys-prop-004 + - phys-prop-005 +similarTo: + - phys-prop-001 + - phys-prop-004 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 29–43." diff --git a/src/content/questions/fundamentals/phys-prop-004.yaml b/src/content/questions/fundamentals/phys-prop-004.yaml new file mode 100644 index 0000000..c51386e --- /dev/null +++ b/src/content/questions/fundamentals/phys-prop-004.yaml @@ -0,0 +1,71 @@ +id: phys-prop-004 +stem: | + A student is testing two colourless oval brilliant stones of similar appearance. Stone A + weighs 0.41 g in air and 0.29 g in water, giving SG = 0.41 / (0.41 − 0.29) = 3.42. + Stone B weighs 0.41 g in air and 0.28 g in water, giving SG = 0.41 / (0.41 − 0.28) + = 3.15. Both give single constant shadow edges on the refractometer. Stone A edge is at + 1.718; Stone B edge is at 1.434. Identify both stones correctly. +type: mcq +options: + - text: "Stone A: spinel (RI 1.718, SG 3.58–3.61); Stone B: fluorite (RI 1.434, SG 3.18) — the SG values are consistent with these identifications within measurement error for small stones" + isCorrect: true + rationale: | + Stone A: RI 1.718 matches spinel (cubic, isotropic, SG typically 3.58–3.61). + The measured SG of 3.42 is somewhat below the typical spinel range; small stones + are harder to measure accurately by hydrostatic weighing due to surface tension + effects, wire-bridge weight, and water-temperature variation. The RI is definitive. + Stone B: RI 1.434 matches fluorite (cubic, isotropic, SG 3.18). The measured 3.15 + is close to the expected 3.18, within the ±0.01–0.02 precision of a careful + hydrostatic measurement on a small stone. Both identifications are internally + consistent. + - text: "Stone A: glass (RI 1.718, SG 3.42); Stone B: quartz (RI 1.434, SG 3.15) — both are low-value simulants" + isCorrect: false + rationale: | + Glass can achieve RI 1.718 by adding heavy-metal oxides, but glass SG at that RI + would be approximately 4.5–5.0 (high-lead glass), not 3.42. Lead glass (strass) + with RI near 1.70 has SG well above 3.5. Standard glass at SG 3.42 would not + reach RI 1.718. Additionally, quartz has RI 1.544–1.553, not 1.434 — Stone B's + RI of 1.434 is far below any quartz value. A student who chooses this answer + has either guessed or confused RI values between species. + - text: "Stone A: grossular garnet (RI ~1.73–1.76, SG ~3.58); Stone B: apatite (RI ~1.63, SG ~3.20) — both close matches by SG" + isCorrect: false + rationale: | + Grossular garnet RI ranges approximately 1.73–1.76, which is well above the + measured shadow edge at 1.718. Even the lower end of grossular (1.73) does not + match 1.718. Apatite RI is approximately 1.630–1.640 with birefringence 0.002–0.006 + — far above the measured 1.434. This distractor exploits SG-first thinking: both + distractors have SGs in the right neighbourhood, but the RI values are + incompatible. In gemmological identification, RI is typically the most precise + instrument measurement; mismatches of 0.02 or more in RI are decisive. +rationaleCorrect: | + Stone A: RI 1.718 is the calibration-standard value for spinel (cubic, isotropic, SG + 3.58–3.61). The SG of 3.42 is below typical range, consistent with measurement + imprecision on a small stone. Stone B: RI 1.434 is the calibration-standard value for + fluorite (cubic, isotropic, SG 3.18). Measured SG 3.15 is close. Both spinel 1.718 and + fluorite 1.434 are the two most important refractometer calibration values: they bracket + the common gem range and are used routinely to verify instrument accuracy. Knowing these + values by heart is a Foundation requirement. Note: both stones are cubic and isotropic, + giving Pattern I (single constant edge) — but they are instantly distinguished by the RI + value alone. +difficulty: 2 +category: fundamentals +conceptTags: + - specific-gravity + - refractive-index + - isotropic + - spinel + - hardness +sourceArticle: fundamentals/physical-properties +examRelevance: FGA-foundation +confusionPairs: + - phys-prop-001 + - phys-prop-002 +similarTo: + - phys-prop-001 + - phys-prop-005 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 29–43." diff --git a/src/content/questions/fundamentals/phys-prop-005.yaml b/src/content/questions/fundamentals/phys-prop-005.yaml new file mode 100644 index 0000000..83f6f6e --- /dev/null +++ b/src/content/questions/fundamentals/phys-prop-005.yaml @@ -0,0 +1,73 @@ +id: phys-prop-005 +stem: | + A gemmologist is presented with three orange stones of similar colour and cut. Physical + testing reveals: Stone 1 — RI 1.620–1.640, SG 3.06, uniaxial negative, strong + pleochroism (orange and yellow); Stone 2 — RI 1.654–1.690, SG 3.34, biaxial positive, + weak pleochroism; Stone 3 — single constant edge RI approximately 1.810 (above normal + refractometer range, reading estimated from edge position), SG 3.84, isotropic. + Identify all three correctly. +type: mcq +options: + - text: "Stone 1: orange tourmaline (elbaite); Stone 2: peridot; Stone 3: spessartine garnet" + isCorrect: true + rationale: | + Stone 1 matches orange tourmaline: trigonal, uniaxial negative, RI 1.620–1.640, + SG 3.06, strong dichroism — tourmaline shows the strongest pleochroism of any + common gem. Stone 2 matches peridot: orthorhombic, biaxial positive, RI 1.654–1.690, + SG 3.28–3.48, weak pleochroism. Stone 3 matches spessartine garnet: cubic (isotropic), + RI approximately 1.800–1.820 (above standard refractometer range), SG 3.8–4.25. + The isotropic behaviour and RI above the scale are characteristic of garnets with + high RI readings. + - text: "Stone 1: sapphire; Stone 2: peridot; Stone 3: spessartine garnet" + isCorrect: false + rationale: | + Sapphire (corundum) is trigonal and uniaxial negative, which matches Stone 1's + optic character. However, sapphire RI is 1.762–1.770 and SG is 3.99–4.01 — both + significantly higher than the measured 1.620–1.640 and 3.06. Orange sapphire + (padparadscha is pink-orange) exists but would give the corundum RI and SG values. + The RI of 1.620–1.640 is far below corundum's range and is instead consistent with + tourmaline. A student might reach for sapphire because it is "the blue stone" and + forgetting that orange sapphire exists — but the RI mismatch eliminates it. + - text: "Stone 1: orange tourmaline; Stone 2: citrine; Stone 3: hessonite grossular garnet" + isCorrect: false + rationale: | + Citrine (yellow quartz) is trigonal, uniaxial positive, RI 1.544–1.553, SG 2.65 — + none of which match Stone 2's biaxial positive character, RI 1.654–1.690, or SG 3.34. + Stone 3's RI near 1.810 is too high for hessonite grossular garnet (RI typically + 1.73–1.76, SG 3.57–3.73). Hessonite can reach RI ~1.76 but not 1.81; an RI of 1.81 + points to spessartine (Mn-Al garnet, RI up to 1.82) or almandine (Fe-Al garnet, RI + 1.77–1.83). A student might choose hessonite because it is an orange garnet, but the + RI value eliminates it. +rationaleCorrect: | + Three orange gems with overlapping colour but distinct properties: (1) Orange tourmaline + (elbaite): uniaxial negative, RI 1.620–1.640, SG 3.06, strong dichroism — the strongest + pleochroism of any uniaxial gem; (2) Peridot (olivine): biaxial positive, RI 1.654–1.690, + SG 3.28–3.48, weak pleochroism — distinctive high birefringence (0.036) makes doubling + visible; (3) Spessartine garnet: cubic/isotropic, RI 1.80–1.82 (above refractometer), + SG 3.8–4.25, no pleochroism — vivid orange-red popular in recent years from Namibia and + Nigeria. The three-way differentiation relies on optic character first (isotropic/uniaxial/ + biaxial), then RI range, then SG. Colour alone cannot distinguish these orange gems. +difficulty: 3 +category: fundamentals +conceptTags: + - refractive-index + - specific-gravity + - optic-character + - pleochroism + - tourmaline + - peridot + - almandine +sourceArticle: fundamentals/physical-properties +examRelevance: FGA-foundation +confusionPairs: + - phys-prop-003 + - phys-prop-004 +similarTo: + - phys-prop-003 + - phys-prop-006 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 29–43." diff --git a/src/content/questions/fundamentals/phys-prop-006.yaml b/src/content/questions/fundamentals/phys-prop-006.yaml new file mode 100644 index 0000000..210691c --- /dev/null +++ b/src/content/questions/fundamentals/phys-prop-006.yaml @@ -0,0 +1,75 @@ +id: phys-prop-006 +stem: | + A student compares two blue oval cabochons of similar appearance. Stone A: SG 3.60, + spot RI approximately 1.72, no fluorescence LWUV, isotropic on polariscope. Stone B: + SG 4.00, spot RI approximately 1.77, strong blue fluorescence SWUV, birefringence + visible as a double image at girdle under loupe. A colleague identifies both as + "blue spinel." Evaluate this identification for each stone separately. +type: mcq +options: + - text: "Stone A is correctly identified as blue spinel (RI 1.712–1.720, SG 3.58–3.61, isotropic); Stone B is misidentified — the SG 4.00, RI ~1.77, and birefringence confirm blue sapphire (corundum), not spinel" + isCorrect: true + rationale: | + Stone A matches spinel precisely: cubic (isotropic), RI 1.712–1.720, SG 3.58–3.61. + No fluorescence is common for iron-bearing blue spinel (cobalt-blue spinel can show + red fluorescence, but iron-blue is typically inert). Stone B cannot be spinel: + SG 4.00 is the definitive value for corundum (SG 3.99–4.01), far above spinel's + 3.58–3.61; visible birefringence under a loupe confirms double refraction, ruling + out isotropic spinel; RI ~1.77 matches corundum (1.762–1.770). Blue sapphire + (corundum) is the correct identification for Stone B. + - text: "Both stones are blue spinel — SG and RI variations are within the normal range for spinel from different localities" + isCorrect: false + rationale: | + Spinel SG does not range up to 4.00 from locality variation alone — the + compositional range (Mg replaced by Fe, Zn, Mn) does slightly shift SG, but + the maximum for gem spinel is approximately 3.63–3.72 for iron-bearing varieties, + not 4.00. A difference of 0.40 SG units between 3.60 and 4.00 cannot be + explained by locality variation; 4.00 is a diagnostic, species-level value for + corundum, not spinel. Additionally, visible birefringence (double image) under a + loupe in Stone B rules out any cubic mineral including spinel — this is an + unambiguous observation, not subject to locality variation. + - text: "Stone A is blue topaz (RI ~1.62, SG ~3.50); Stone B is blue sapphire — blue topaz can appear similar to spinel but is biaxial" + isCorrect: false + rationale: | + Blue topaz RI is 1.619–1.627, clearly below the spot reading of approximately + 1.72. Stone A's spot RI of ~1.72 is too high for topaz and matches spinel. Blue + topaz is also biaxial (orthorhombic) and would show birefringence on a polariscope, + not isotropic behaviour. Stone A's isotropic polariscope result is inconsistent + with topaz. This distractor tests whether students can maintain both properties + simultaneously — RI and optic character must both match, not just the colour or + approximate SG. +rationaleCorrect: | + Stone A: spinel (RI 1.718, SG 3.60, isotropic) — all three properties within published + spinel range. Stone B: blue sapphire (corundum, RI 1.762–1.770, SG 4.00, uniaxial + negative with visible birefringence) — the SG of 4.00 is one of the most precise and + diagnostic values in gemmology (corundum SG 3.99–4.01). Blue sapphire and blue spinel + are the classic pair historically confused before the advent of gemmological instruments — + the Black Prince's Ruby and Timur Ruby in the English crown jewels are actually red + spinels mistaken for rubies for centuries. The refractometer and SG measurement instantly + resolve the confusion. Never accept "both are spinel" when one has SG 4.00 and + birefringence — those are corundum properties. +difficulty: 3 +category: fundamentals +conceptTags: + - specific-gravity + - refractive-index + - optic-character + - isotropic + - spinel + - corundum + - birefringence +sourceArticle: fundamentals/physical-properties +examRelevance: FGA-foundation +confusionPairs: + - phys-prop-005 + - phys-prop-007 + - corundum-vs-spinel-001 +similarTo: + - phys-prop-004 + - corundum-vs-spinel-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 29–43, 102–103." diff --git a/src/content/questions/fundamentals/phys-prop-007.yaml b/src/content/questions/fundamentals/phys-prop-007.yaml new file mode 100644 index 0000000..17ee3cb --- /dev/null +++ b/src/content/questions/fundamentals/phys-prop-007.yaml @@ -0,0 +1,72 @@ +id: phys-prop-007 +stem: | + A student receives an unset faceted green stone. Testing reveals: RI 1.577–1.583, + birefringence 0.006, uniaxial negative, SG 2.71, hardness approximately 7.5–8, + inert under both LWUV and SWUV. A second green stone gives: RI 1.620–1.640, + birefringence approximately 0.020, uniaxial negative, SG 3.06. Both are vivid + medium-dark green. Which identification is correct for each stone? +type: mcq +options: + - text: "Stone 1: emerald (beryl, uniaxial negative, RI 1.577–1.583, SG 2.67–2.78, hardness 7.5–8); Stone 2: green tourmaline (indicolite/verdelite, uniaxial negative, RI 1.620–1.640, SG 3.06)" + isCorrect: true + rationale: | + Stone 1 matches emerald (green beryl): hexagonal, uniaxial negative, RI 1.577–1.583, + SG 2.67–2.78, hardness 7.5–8. The inert UV response is typical for non-Cr/non-V + beryl varieties or some treated emeralds; Cr-bearing Colombian emeralds often show + red fluorescence LWUV, but the UV response alone does not exclude emerald. Stone 2 + matches green tourmaline (verdelite or chrome tourmaline): trigonal, uniaxial negative, + RI 1.620–1.640, birefringence up to 0.020 (one of the highest of common gems), SG 3.06. + Both stones are uniaxial negative — the key discriminator is RI range and SG. + - text: "Stone 1: green tourmaline; Stone 2: emerald" + isCorrect: false + rationale: | + The two identifications are reversed. Green tourmaline RI is 1.620–1.640, not + 1.577–1.583. Emerald (beryl) RI is 1.577–1.583, not 1.620–1.640. The RI ranges + are well separated (by 0.04–0.06 units) and represent one of the most important + pairings in FGA examination practice. Swapping them is a common error when students + memorise a list of "green gems" without anchoring each species to its specific RI + range. The birefringence values also differ: emerald 0.005–0.009, tourmaline up + to 0.020 — a clear numerical discriminator. + - text: "Both stones are green sapphire (corundum); the RI range 1.577–1.583 represents omega only, while 1.620–1.640 represents different cut orientations of corundum" + isCorrect: false + rationale: | + Corundum RI is 1.762–1.770 — neither 1.577–1.583 nor 1.620–1.640 is within + corundum's published range by a large margin (0.18+ units difference). No + orientation of corundum can give an RI reading of 1.577 on a standard refractometer; + the minimum omega for corundum is 1.762. This distractor is designed to catch + students who have confused beryl or tourmaline RI values with corundum, which + would represent a serious property recall failure with significant practical + consequences. +rationaleCorrect: | + Green beryl (emerald) and green tourmaline are both uniaxial negative — a property + they share. The discrimination rests entirely on RI range and SG: emerald RI 1.577–1.583, + SG 2.67–2.78; tourmaline RI 1.620–1.640, SG 3.06. The ~0.04 RI gap between maximum + emerald (1.583) and minimum tourmaline (1.620) is large enough to be read clearly on + the refractometer even in the absence of SG data. In a practical exam, a student who + sees 1.577–1.583 with uniaxial negative should write "beryl/emerald" without hesitation. + A value of 1.620–1.640 with uniaxial negative and birefringence 0.020 is tourmaline. + UV fluorescence adds information (Cr-bearing emerald may fluoresce red LWUV) but is not + needed for the fundamental species identification. +difficulty: 2 +category: fundamentals +conceptTags: + - refractive-index + - specific-gravity + - uniaxial + - birefringence + - emerald + - tourmaline +sourceArticle: fundamentals/physical-properties +examRelevance: FGA-foundation +confusionPairs: + - phys-prop-005 + - phys-prop-008 +similarTo: + - phys-prop-005 + - phys-prop-008 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 29–43." diff --git a/src/content/questions/fundamentals/phys-prop-008.yaml b/src/content/questions/fundamentals/phys-prop-008.yaml new file mode 100644 index 0000000..e7bcef3 --- /dev/null +++ b/src/content/questions/fundamentals/phys-prop-008.yaml @@ -0,0 +1,75 @@ +id: phys-prop-008 +stem: | + A kyanite crystal is being assessed for cutting. A student measures the hardness + in two directions: parallel to the length of the crystal (along the c-axis) and + perpendicular to the length. The readings are markedly different — approximately + 4.5–5 parallel to the c-axis and 6.5–7 perpendicular. The student asks why the + hardness differs so dramatically in a single mineral species. What is the correct + mineralogical explanation? +type: mcq +options: + - text: "Kyanite shows directional hardness (anisotropic hardness) because the crystal structure has different bond strengths along different crystallographic directions; along the c-axis, bonds are weaker and easier to scratch" + isCorrect: true + rationale: | + Directional hardness (also called anisotropic hardness) occurs in minerals whose + crystal structure has different bond strengths in different crystallographic + directions. In kyanite (Al2SiO5, triclinic), the aluminium-oxygen bond geometry + produces weaker planes parallel to the length (c-axis direction), giving hardness + 4.5–5 in that direction, and stronger planes perpendicular, giving 6.5–7. + This property gave kyanite its historical name "disthene" (two strengths). The + same mineral — same crystal, same composition — scratches easily along one + direction and resists scratching in another. + - text: "The two measurements were taken on different zones of the crystal that have different compositions due to chemical zoning, causing the hardness variation" + isCorrect: false + rationale: | + Chemical zoning (compositional variation across a crystal from core to rim) + can affect colour and sometimes RI in gem minerals, but it does not cause the + strong, reproducible, direction-specific hardness variation seen in kyanite. + If the hardness difference were from zoning, it would vary from crystal to crystal + and from zone to zone — not reliably in the same directions across different crystals + of the same species. Kyanite's directional hardness is a fundamental, repeatable + crystallographic property that every kyanite crystal displays, not an artefact of + individual chemical variation. + - text: "Kyanite has a perfect cleavage in the c-axis direction, and the hardness tester scratches along the cleavage plane, producing a false low reading" + isCorrect: false + rationale: | + Kyanite does have good to perfect cleavage in two directions ({100} and {010}), + but this does not explain the hardness measurement result through a "false reading." + Cleavage affects how the mineral fractures, not how it resists a scratch. The + hardness tester measures resistance to a scratch groove, which is a genuine material + property measured in the direction of the test. The 4.5–5 value parallel to c is a + real, reproducible hardness value, not a measurement artefact from the cleavage. + The two properties — cleavage and directional hardness — coexist independently in + kyanite's structure. +rationaleCorrect: | + Kyanite is the classic gemmological demonstration of directional hardness (anisotropic + hardness). The Mohs scale assumes hardness is a single value per mineral; kyanite shows + it can vary by 2 full Mohs units depending on the direction of the test. This has + significant practical implications: cutting and polishing kyanite is challenging because + the polishing wheel must be oriented correctly for each facet. Diamond polishing powder + works regardless of direction because the random orientation of grit particles always + presents some particles in their hardest direction. Directional hardness is not unique + to kyanite — diamond also varies by direction (hardest along octahedral, softest across + dodecahedral) — but kyanite is the most extreme and pedagogically useful example in + gemmology. +difficulty: 2 +category: fundamentals +conceptTags: + - hardness + - crystal-system + - triclinic-system + - cleavage +sourceArticle: fundamentals/physical-properties +examRelevance: FGA-foundation +confusionPairs: + - crystal-system-007 + - phys-prop-009 +similarTo: + - crystal-system-007 + - phys-prop-007 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 29–32." diff --git a/src/content/questions/fundamentals/phys-prop-009.yaml b/src/content/questions/fundamentals/phys-prop-009.yaml new file mode 100644 index 0000000..01bb221 --- /dev/null +++ b/src/content/questions/fundamentals/phys-prop-009.yaml @@ -0,0 +1,72 @@ +id: phys-prop-009 +stem: | + A gemmologist needs to distinguish between nephrite jade (H 6–6.5, SG 2.95–3.10, + aggregate structure) and jadeite (H 6.5–7, SG 3.25–3.35, aggregate structure) from a + polished flat surface. Standard refractometer testing gives a spot reading of approximately + 1.61 for Stone A and 1.65 for Stone B. Both stones show a "sugary" or interlocking + granular texture under the microscope. Which assignment is correct, and which property + is most diagnostic? +type: mcq +options: + - text: "Stone A (spot RI ~1.61) is nephrite; Stone B (spot RI ~1.65) is jadeite; the spot RI is the most diagnostic single property for separating the two jade types" + isCorrect: true + rationale: | + Nephrite (calcium-magnesium-iron amphibole aggregate) has RI approximately 1.60–1.63 + with a spot reading typically around 1.61. Jadeite (sodium aluminium pyroxene + aggregate) has RI approximately 1.65–1.68 with a spot reading typically around 1.65. + The approximately 0.04 unit gap between the two spot readings is sufficient to + distinguish them on the refractometer with reasonable care, making the spot RI + the most practical single test. SG also separates them (nephrite 2.95–3.10 vs + jadeite 3.25–3.35) but requires hydrostatic weighing equipment. + - text: "Stone A is jadeite; Stone B is nephrite — the RI values for jade types are frequently confused" + isCorrect: false + rationale: | + The assignment is reversed. Jadeite has the higher RI (~1.65 spot) because of its + denser pyroxene (NaAlSi2O6) structure; nephrite has the lower RI (~1.61 spot) due + to the tremolite-actinolite amphibole structure. A student who chooses this answer + has either memorised the values in the wrong order or is guessing. The RI values + for both jade types are key Foundation-level data: nephrite always gives the lower + spot reading (approximately 1.60–1.63), jadeite the higher (approximately 1.65–1.68). + - text: "Both stones are jadeite; nephrite cannot be polished to give a flat surface suitable for refractometer reading" + isCorrect: false + rationale: | + Nephrite can be polished to a high lustre on flat surfaces; it is regularly + used as polished slabs and cabochons where a spot reading is entirely feasible. + Nephrite's fibrous, interlocking amphibole structure gives it exceptional toughness + but does not prevent a polished surface suitable for gemmological testing. + The spot reading technique is specifically designed for curved and flat polished + surfaces that are not faceted — it works well on both jade types. Denying that + nephrite can be tested by spot method would represent a practical error in a + laboratory setting. +rationaleCorrect: | + Nephrite and jadeite are both "jade" but are entirely different minerals — nephrite is + an amphibole aggregate (tremolite-actinolite), jadeite is a pyroxene single mineral. + Their separation is one of the most practically important distinctions in gem trade, + as top-quality "Imperial" jadeite (translucent vivid green, Myanmar) commands prices + far exceeding nephrite. The spot RI is the most immediately accessible test: nephrite + ~1.60–1.63, jadeite ~1.65–1.68. SG confirms: nephrite 2.95–3.10, jadeite 3.25–3.35. + Both show polycrystalline texture under the microscope; spectroscopy (jadeite + absorption at 437 nm — always present, even in non-green jadeite) provides additional + confirmation. Never identify jade by colour alone: dyed quartzite and glass can mimic + green colour convincingly. +difficulty: 3 +category: fundamentals +conceptTags: + - refractive-index + - specific-gravity + - hardness + - monoclinic-system +sourceArticle: fundamentals/physical-properties +examRelevance: FGA-foundation +confusionPairs: + - phys-prop-010 + - phys-prop-003 +similarTo: + - phys-prop-003 + - phys-prop-010 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 29–43." diff --git a/src/content/questions/fundamentals/phys-prop-010.yaml b/src/content/questions/fundamentals/phys-prop-010.yaml new file mode 100644 index 0000000..5ebf9e8 --- /dev/null +++ b/src/content/questions/fundamentals/phys-prop-010.yaml @@ -0,0 +1,78 @@ +id: phys-prop-010 +stem: | + During a practical, a student is handed a translucent white to pale-blue cabochon + and asked to identify it. The stone weighs 1.42 g in air and 0.95 g in water + (SG = 3.02). The spot reading on the refractometer gives approximately 1.53. The + stone shows a strong adularescence (floating blue sheen) when rotated under a + directional light source. No fluorescence under UV. Which species is this, and + which near-miss species does the adularescence immediately eliminate? +type: mcq +options: + - text: "Moonstone (orthoclase feldspar), adularescence from light scattering between albite lamellae; the optical phenomenon eliminates white chalcedony and milky quartz, which show no adularescence" + isCorrect: true + rationale: | + Adularescence — the floating blue-white sheen caused by light scattering between + alternating layers of orthoclase and albite — is virtually diagnostic for moonstone + (adularia/orthoclase feldspar). The SG of 3.02 is above the normal orthoclase + range of 2.56–2.59, suggesting this might be a high-SG variety or include a + measurement error; however, the spot RI ~1.53 (orthoclase RI ~1.52–1.54) and + adularescence together strongly indicate moonstone. White chalcedony (SG 2.58–2.64, + RI 1.535) and milky quartz (SG 2.65, RI 1.544) both lack adularescence and would + not be mistaken for moonstone under a directional light source. + - text: "Peristerite (plagioclase feldspar showing adularescence), SG 2.62–2.65, RI 1.53–1.55; the properties are consistent with plagioclase, not orthoclase" + isCorrect: false + rationale: | + Peristerite is an albite-rich plagioclase that can show adularescence (sometimes + called "rainbow moonstone" in the trade, though this is a misnomer). Peristerite + SG is typically 2.62–2.65 and RI approximately 1.53–1.55 — overlapping partially + with orthoclase. The measured SG of 3.02 is too high for peristerite; more + importantly, distinguishing peristerite from orthoclase moonstone requires precise + RI measurement and spectroscopy, not the spot method. The question's data is + more consistent with orthoclase, and a gemmologist would record "feldspar + moonstone — type determination pending further testing" rather than definitively + assigning peristerite. + - text: "Labradorite (plagioclase feldspar), SG 2.69–2.72, RI 1.56–1.57; labradorescence is the same phenomenon as adularescence" + isCorrect: false + rationale: | + Labradorescence (the spectral colour play of labradorite) and adularescence + (the floating blue sheen of moonstone) are related but distinct phenomena. + Both involve thin-film interference or scattering between exsolution lamellae, + but the result differs: adularescence is a floating, mobile blue-white glow; + labradorescence produces vivid spectral colours (blues, greens, golds) that + change with angle. Labradorite SG 2.69–2.72 and RI 1.56–1.57 are also clearly + different from the measured values. Conflating the two phenomena is a common + student error — both are feldspar optical effects, but the visual appearance + and mineral species differ. +rationaleCorrect: | + Moonstone (adularia/orthoclase feldspar) is identified by its adularescence — a + floating blue-white sheen caused by Rayleigh scattering from submicroscopic albite + lamellae exsolved within the orthoclase host. This optical effect is specific to + moonstone among common gem feldspars and distinguishes it from quartz-group stones + (which do not show adularescence) and labradorite (which shows labradorescence — + spectral colour play, visually distinct). SG ~2.56–2.59 for pure orthoclase; RI + 1.518–1.526 (alpha) to 1.524–1.533 (gamma), mean ~1.52–1.53. The spot reading of + ~1.53 is consistent. Note: "rainbow moonstone" in the trade is often labradorite, + not orthoclase moonstone — the trade name is misleading. A gemmologist must distinguish + them by RI, SG, and the character of the optical phenomenon. +difficulty: 3 +category: fundamentals +conceptTags: + - refractive-index + - specific-gravity + - lustre + - triclinic-system + - monoclinic-system +sourceArticle: fundamentals/physical-properties +examRelevance: FGA-foundation +confusionPairs: + - phys-prop-009 + - phys-prop-007 +similarTo: + - phys-prop-009 + - phys-prop-007 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 29–43." From 2bbdfaf60366e8c886e09d52ecad9191a0ba5c55 Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:49:56 +0700 Subject: [PATCH 13/16] feat(study-content): add 10 species questions on five-mineral discrimination clusters Red stone cluster (ruby/spinel/garnet/red-glass/synthetic-ruby): corundum-vs-spinel-001 through corundum-vs-spinel-003, red-stone-garnet-001, red-stone-glass-001. Green stone cluster (emerald/chrome-tourmaline/green-glass/demantoid/peridot): green-stone-001 through green-stone-005. Cross-referencing confusionPairs throughout. --- .../species/corundum-vs-spinel-001.yaml | 77 ++++++++++++++++++ .../species/corundum-vs-spinel-002.yaml | 79 ++++++++++++++++++ .../species/corundum-vs-spinel-003.yaml | 76 ++++++++++++++++++ .../questions/species/green-stone-001.yaml | 77 ++++++++++++++++++ .../questions/species/green-stone-002.yaml | 74 +++++++++++++++++ .../questions/species/green-stone-003.yaml | 76 ++++++++++++++++++ .../questions/species/green-stone-004.yaml | 80 +++++++++++++++++++ .../questions/species/green-stone-005.yaml | 79 ++++++++++++++++++ .../species/red-stone-garnet-001.yaml | 80 +++++++++++++++++++ .../species/red-stone-glass-001.yaml | 77 ++++++++++++++++++ 10 files changed, 775 insertions(+) create mode 100644 src/content/questions/species/corundum-vs-spinel-001.yaml create mode 100644 src/content/questions/species/corundum-vs-spinel-002.yaml create mode 100644 src/content/questions/species/corundum-vs-spinel-003.yaml create mode 100644 src/content/questions/species/green-stone-001.yaml create mode 100644 src/content/questions/species/green-stone-002.yaml create mode 100644 src/content/questions/species/green-stone-003.yaml create mode 100644 src/content/questions/species/green-stone-004.yaml create mode 100644 src/content/questions/species/green-stone-005.yaml create mode 100644 src/content/questions/species/red-stone-garnet-001.yaml create mode 100644 src/content/questions/species/red-stone-glass-001.yaml diff --git a/src/content/questions/species/corundum-vs-spinel-001.yaml b/src/content/questions/species/corundum-vs-spinel-001.yaml new file mode 100644 index 0000000..a5aa42c --- /dev/null +++ b/src/content/questions/species/corundum-vs-spinel-001.yaml @@ -0,0 +1,77 @@ +id: corundum-vs-spinel-001 +stem: | + A polished red stone gives the following instrument readings in the gemmology + laboratory: RI 1.762–1.770 (two edges touching at 1.762 during rotation), SG 4.00, + polariscope — lights up at four positions during rotation, LWUV — strong red fluorescence. + Three candidate identifications are proposed: natural ruby, spinel, or pyrope garnet. + Which candidate is correct and what is the diagnostic reasoning chain? +type: mcq +options: + - text: "Natural ruby (corundum); doubly refractive on polariscope eliminates spinel and pyrope, SG 4.00 eliminates pyrope, LWUV fluorescence and RI 1.762–1.770 confirm corundum" + isCorrect: true + rationale: | + The polariscope result (lighting at four positions = double refraction) immediately + eliminates isotropic spinel and isotropic pyrope garnet — both are cubic and remain + dark between crossed polars. Of the three candidates, only corundum is doubly + refractive (uniaxial negative). RI 1.762–1.770 matches corundum precisely; spinel + RI is 1.712–1.720 (too low); pyrope RI is 1.730–1.760 (too low). SG 4.00 matches + corundum (3.99–4.01); pyrope SG is 3.65–3.87 (too low); spinel SG is 3.58–3.61 + (too low). Strong red LWUV fluorescence from chromium confirms ruby. + - text: "Spinel; red spinel also shows strong red LWUV fluorescence and RI near 1.718, and fluorescence is the most diagnostic property for red stones" + isCorrect: false + rationale: | + Red spinel does fluoresce strongly red under LWUV, which is correct — this is why + historical gem traders frequently confused spinel with ruby. However, fluorescence + is explicitly the LEAST diagnostic property here because both ruby and Burma spinel + can show red LWUV fluorescence. The decisive observations are: (1) the polariscope + showing double refraction — spinel is isotropic and stays dark; (2) RI 1.762–1.770 + — spinel RI is 1.712–1.720, clearly lower; (3) SG 4.00 — spinel SG is 3.58–3.61, + clearly lower. Choosing fluorescence as the most diagnostic property for this + discrimination is a textbook error. + - text: "Pyrope garnet; pyrope is deep red and can be confused with ruby; the polariscope result suggests pyrope because garnets often show anomalous double refraction" + isCorrect: false + rationale: | + Anomalous double refraction (ADR) in garnets appears as patchy or mottled light + patterns between crossed polars — not the clean four-position lighting of true + double refraction. More importantly, pyrope is cubic and should appear predominantly + dark; ADR is an irregular strain effect, not systematic double refraction. The + stone described lights up clearly at four positions, which is the behaviour of a + genuinely doubly refractive gem (corundum), not ADR from a garnet. Additionally, + pyrope RI is 1.730–1.760 and SG 3.65–3.87 — both below the measured values, + providing additional property-based elimination independent of the polariscope. +rationaleCorrect: | + The ruby/spinel/pyrope discrimination is the most historically important near-miss + problem in gemmology — the Black Prince's Ruby (Tower of London) is a red spinel. + The diagnostic chain is always: optic character first (doubly refractive = not spinel, + not garnet), then RI (1.762–1.770 = corundum range), then SG (4.00 = corundum). + Fluorescence confirms but never establishes identification — some Burma spinel + also fluoresces red. A clean four-position lighting on the polariscope is the + single observation that eliminates both cubic candidates simultaneously, before + any measurement is taken. This sequence (polariscope → refractometer → SG) is + the standard FGA practical workflow for red stones. +difficulty: 3 +category: species +conceptTags: + - refractive-index + - specific-gravity + - optic-character + - fluorescence-LWUV + - corundum + - ruby + - spinel + - pyrope +sourceArticle: species/corundum +examRelevance: FGA-foundation +confusionPairs: + - corundum-vs-spinel-002 + - red-stone-garnet-001 + - corundum-vs-spinel-003 +similarTo: + - corundum-vs-spinel-002 + - ri-reading-010 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 102–115." diff --git a/src/content/questions/species/corundum-vs-spinel-002.yaml b/src/content/questions/species/corundum-vs-spinel-002.yaml new file mode 100644 index 0000000..1f18390 --- /dev/null +++ b/src/content/questions/species/corundum-vs-spinel-002.yaml @@ -0,0 +1,79 @@ +id: corundum-vs-spinel-002 +stem: | + A gemmologist examines a faceted red stone that gives a single constant shadow edge + on the refractometer at approximately 1.718, remains uniformly dark on the polariscope + throughout a full rotation, and has SG 3.60. Under LWUV it gives a moderate red + fluorescence. The stone's owner insists it is a valuable natural ruby inherited from + their grandmother. How should the gemmologist respond, and which three properties + collectively eliminate ruby as the identification? +type: mcq +options: + - text: "The stone is spinel, not ruby; RI 1.718 (not 1.762–1.770), isotropic polariscope (not doubly refractive), and SG 3.60 (not 4.00) all simultaneously exclude corundum" + isCorrect: true + rationale: | + All three measured properties exclude ruby: (1) RI 1.718 is the published value + for spinel, not corundum (1.762–1.770 — a gap of 0.044 RI units, easily read on + a calibrated instrument); (2) isotropic polariscope (stays uniformly dark = no + double refraction) rules out corundum, which is uniaxial negative and lights up + between crossed polars; (3) SG 3.60 matches spinel (3.58–3.61) and is far below + corundum (3.99–4.01). The red LWUV fluorescence is consistent with both species + and does not establish the identification. The gemmologist should explain tactfully + that the stone is red spinel — historically often more beautiful than ruby, and + valuable in its own right. + - text: "The stone could be ruby; the grandmother may have owned a thermally treated ruby where heat treatment lowers the SG and RI to spinel-like values" + isCorrect: false + rationale: | + Heat treatment of ruby does not alter its RI or SG to match spinel values. These + are intrinsic properties of the crystal structure (Al2O3 for corundum vs. MgAl2O4 + for spinel) — they cannot be changed by any heat treatment. A heat-treated ruby + still has RI 1.762–1.770 and SG 3.99–4.01. The measured values (RI 1.718, SG + 3.60) cannot be corundum before or after treatment. This suggestion, if made to + a client, would constitute professional misinformation and potentially expose the + gemmologist to liability. + - text: "The stone might be a synthetic ruby; synthetic rubies have lower RI and SG than natural rubies because the flux growth process dilutes the aluminium oxide with flux material" + isCorrect: false + rationale: | + Synthetic ruby, whether flame-fusion (Verneuil), flux-grown, or hydrothermal, + has exactly the same chemical composition (Al2O3) and therefore the same RI + (1.762–1.770) and SG (3.99–4.01) as natural ruby. The growth process does not + dilute the corundum lattice — the resulting crystal is pure Al2O3 with the same + physical properties. Flux inclusions may be present as inclusions, but the host + crystal's RI and SG are indistinguishable from natural. The measured 1.718 and + 3.60 cannot be any form of corundum, natural or synthetic. +rationaleCorrect: | + This is the classic red spinel/ruby confusion — the most commercially important + gem misidentification in history. Red spinel (MgAl2O4) and ruby (Al2O3) were + considered the same gem for centuries because both are red and often found together + in alluvial deposits in Myanmar. The three-property elimination: (1) RI 1.718 is + spinel; (2) isotropic polariscope is spinel (cubic); (3) SG 3.60 is spinel. All + three are required — any one alone could theoretically be argued, but all three + together are conclusive. The gemmologist's communication must be tactful: the + stone is fine red spinel, which is valuable and beautiful in its own right — in + recent years fine red spinel has achieved prices per carat approaching those of + ruby. Reframing the conversation from "it is not ruby" to "it is rare fine spinel" + is better gemmological practice. +difficulty: 3 +category: species +conceptTags: + - refractive-index + - specific-gravity + - optic-character + - fluorescence-LWUV + - spinel + - ruby + - corundum +sourceArticle: species/spinel +examRelevance: FGA-foundation +confusionPairs: + - corundum-vs-spinel-001 + - corundum-vs-spinel-003 + - red-stone-garnet-001 +similarTo: + - corundum-vs-spinel-001 + - red-stone-garnet-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 102–115." diff --git a/src/content/questions/species/corundum-vs-spinel-003.yaml b/src/content/questions/species/corundum-vs-spinel-003.yaml new file mode 100644 index 0000000..8572a68 --- /dev/null +++ b/src/content/questions/species/corundum-vs-spinel-003.yaml @@ -0,0 +1,76 @@ +id: corundum-vs-spinel-003 +stem: | + During a practical session, a student tests a red stone that gives the following + readings: RI 1.762–1.770 (two-edge refractometer pattern, edges touching), SG 4.00, + uniaxial negative on polariscope. Under the microscope the student sees a dense + network of very fine needle-like inclusions intersecting at approximately 60° angles, + plus several small octahedral negative crystals. A colleague suggests the stone is + synthetic ruby. What microscopic evidence would confirm or deny synthetic origin, + and what is the most likely identity here? +type: mcq +options: + - text: "Natural ruby; silk (rutile needles at 60°) and negative crystals are characteristic natural inclusions — synthetic ruby shows curved growth striae (Verneuil) or flux fingerprints (flux-grown), neither of which matches the described features" + isCorrect: true + rationale: | + Rutile silk (TiO2 needles at 60° intersections) is an exsolution feature formed + during slow natural cooling of corundum — the rutile exsolves from the corundum + lattice as the crystal cools. This process cannot occur in the rapid Verneuil + (flame fusion) synthesis, which produces a boule in hours. Flux-grown synthetic + ruby shows flux fingerprints (curved or irregular growth planes with flux residue) + and wispy flux inclusions, not straight intersecting needles. Octahedral negative + crystals are voids with the crystal habit of the host mineral — a natural feature. + None of these are diagnostic of synthetic origin. + - text: "Synthetic ruby; the high SG of 4.00 suggests flux contamination increased the density above natural ruby values" + isCorrect: false + rationale: | + SG 4.00 is the standard value for natural ruby (corundum, Al2O3) — it is not + "above natural" and it does not indicate flux contamination. Flux-grown synthetic + ruby has the same SG as natural ruby because the host crystal is pure Al2O3; + any flux inclusions present are minor and would slightly lower SG (if flux-rich + inclusions are lighter) or have negligible effect. The premise of this distractor + is factually incorrect. This tests whether students apply correct reasoning to + SG values for corundum, rather than misinterpreting a routine value as anomalous. + - text: "Synthetic ruby (Verneuil process); Verneuil rubies routinely show rutile silk inclusions because the process uses TiO2 as a dopant to produce the characteristic silk" + isCorrect: false + rationale: | + The Verneuil (flame fusion) process does not use TiO2 as a dopant and does not + produce true rutile silk. Verneuil rubies show characteristic curved growth striae + — fine curved lines visible under darkfield illumination, reflecting the curved + growth front of the boule. They may contain minute gas bubbles and occasionally + unmelted powder. The presence of straight, intersecting rutile needles at 60° + is a natural formation indicator, not a Verneuil feature. Claiming that Verneuil + synthesis produces silk would be a factual error in a gemmological report. +rationaleCorrect: | + The microscopic features described — rutile silk at 60° intersections and octahedral + negative crystals — are both natural inclusion types that cannot be replicated by + synthetic processes. Rutile silk arises from exsolution during slow geological cooling; + it is one of the most important natural origin indicators in corundum. Synthetic rubies + have their own diagnostic inclusion suites: Verneuil shows curved growth striae and + gas bubbles; flux-grown shows flux fingerprints, wispy flux, and sometimes flux-filled + fractures; hydrothermal shows chevron growth patterns. Knowing these diagnostic inclusions + is fundamental to the FGA practical examination and to responsible laboratory reporting. +difficulty: 4 +category: species +conceptTags: + - refractive-index + - optic-character + - silk-inclusion + - corundum + - ruby + - synthetic-ruby + - natural-vs-synthetic +sourceArticle: species/corundum +examRelevance: FGA-foundation +confusionPairs: + - corundum-vs-spinel-001 + - corundum-vs-spinel-002 + - red-stone-garnet-001 +similarTo: + - corundum-vs-spinel-001 + - red-stone-garnet-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 102–115." diff --git a/src/content/questions/species/green-stone-001.yaml b/src/content/questions/species/green-stone-001.yaml new file mode 100644 index 0000000..5633612 --- /dev/null +++ b/src/content/questions/species/green-stone-001.yaml @@ -0,0 +1,77 @@ +id: green-stone-001 +stem: | + A vivid green oval cabochon is submitted for testing. Results: spot RI approximately + 1.58, SG 2.71, inert under both LWUV and SWUV, strong dichroism (blue-green and + yellow-green) visible in the dichroscope. Under the microscope the gemmologist sees + a characteristic "garden" (jardin) of irregular parallel growth tubes, two-phase + inclusions, and a three-phase inclusion with a cuboid crystal in the fluid. What is + the identification, and which inclusion type is virtually diagnostic for a specific + geographic origin? +type: mcq +options: + - text: "Colombian emerald (beryl); three-phase inclusions with a halite cube are virtually diagnostic of Colombian origin; the jardin and two-phase inclusions confirm natural emerald" + isCorrect: true + rationale: | + The three-phase inclusion — liquid + gas bubble + solid cubic crystal (halite, + NaCl) — is one of the most celebrated diagnostic features in gemmology: it is + virtually diagnostic of Colombian origin for emerald. The RI ~1.58 (beryl range + 1.577–1.583), SG 2.71 (beryl range 2.67–2.78), dichroism in blue-green and + yellow-green, and parallel growth tubes all confirm emerald. The jardin (from + French, garden) of inclusions is characteristic of natural emerald. Inert UV + response may indicate iron-rich or non-Cr emerald; Cr-bearing Colombian emeralds + sometimes show red LWUV fluorescence, but not always. + - text: "Chrome tourmaline; chrome tourmaline gives vivid green with dichroism, RI in the beryl range, and three-phase inclusions" + isCorrect: false + rationale: | + Chrome tourmaline (Cr-bearing elbaite/dravite) is vivid green with strong + dichroism — correct. However, tourmaline RI is 1.620–1.640, not approximately + 1.58. A spot reading of 1.58 falls well below tourmaline's minimum RI. Three-phase + inclusions with a halite cube are not a known feature of tourmaline — they are + specific to the saline-fluid formation environment of Colombian emerald. Chrome + tourmaline inclusions include growth tubes parallel to c-axis and fluid inclusions, + but not the characteristic NaCl halite cubes. The RI mismatch alone eliminates + tourmaline. + - text: "Tsavorite garnet (grossular); tsavorite is vivid green, has SG approximately 3.61, and shows strong dichroism" + isCorrect: false + rationale: | + Tsavorite (chrome/vanadium grossular garnet) is cubic and therefore isotropic — + it shows no dichroism or pleochroism. The strong dichroism observed in this stone + (two distinct colours in the dichroscope) eliminates any cubic gem, including all + garnets. Additionally, tsavorite SG is approximately 3.58–3.73, clearly higher + than the measured 2.71. The spot RI of approximately 1.58 is also far below + tsavorite's RI of approximately 1.740–1.760. Three property mismatches — isotropy, + SG, and RI — all eliminate tsavorite. +rationaleCorrect: | + Colombian emerald identification relies on a convergent suite of evidence. The + three-phase inclusion (liquid + gas + halite cube) is so characteristic that its + presence alone, in a green beryl with the correct RI and SG, constitutes very strong + evidence of Colombian origin — it is listed as a virtual diagnostic in GIA and FGA + training materials. The jardin of irregular inclusions and two-phase cavities adds + supporting natural-origin evidence. Beryl RI 1.577–1.583, SG 2.67–2.78 confirms the + species. The dichroism (blue-green / yellow-green) is characteristic of Cr-bearing + emerald. Note: UV response varies — some Colombian emeralds fluoresce, others do not, + depending on iron content, so UV inertness does not exclude Colombia. +difficulty: 4 +category: species +conceptTags: + - three-phase-inclusion + - refractive-index + - specific-gravity + - emerald + - beryl + - pleochroism +sourceArticle: species/beryl +examRelevance: FGA-foundation +confusionPairs: + - green-stone-002 + - green-stone-003 + - green-stone-004 +similarTo: + - green-stone-002 + - green-stone-003 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 118–130." diff --git a/src/content/questions/species/green-stone-002.yaml b/src/content/questions/species/green-stone-002.yaml new file mode 100644 index 0000000..ab84231 --- /dev/null +++ b/src/content/questions/species/green-stone-002.yaml @@ -0,0 +1,74 @@ +id: green-stone-002 +stem: | + A gemmologist tests a vivid grass-green faceted oval stone. RI: two edges touching + on the refractometer, constant at 1.620, variable up to 1.637. SG approximately 3.06. + Strong dichroism: dark green / yellowish-green. LWUV: inert. SWUV: inert. Polariscope: + lights up at four positions confirming double refraction. The stone is being offered + as "fine Colombian emerald." Can this be emerald, and what is the correct identification? +type: mcq +options: + - text: "Chrome tourmaline, not emerald; RI 1.620–1.637 with birefringence ~0.017 and SG 3.06 match tourmaline, not beryl (RI 1.577–1.583, SG 2.67–2.78)" + isCorrect: true + rationale: | + The RI range 1.620–1.637 is clearly above the maximum beryl RI of 1.583 by + approximately 0.037 units — a large discrepancy on the refractometer. Beryl RI + is 1.577–1.583 with birefringence 0.005–0.009; the measured birefringence of + ~0.017 is too high for beryl. SG 3.06 matches tourmaline (SG 3.02–3.10) and is + well above beryl (2.67–2.78). Chrome tourmaline (Cr-bearing elbaite) is a vivid + green gem that can appear visually similar to fine emerald in colour. This is + one of the most commercially important near-miss confusions in the green gem market. + - text: "Fine Colombian emerald; Colombian emeralds sometimes give higher RI readings due to their high chromium content raising the index" + isCorrect: false + rationale: | + Chromium content does not raise beryl's RI to 1.620–1.637. Chromium substitutes + for aluminium in the beryl structure and is present in concentrations of typically + less than 1% — too little to measurably shift the RI away from the beryl range + of 1.577–1.583. The published RI for the finest Cr-rich Colombian emerald is still + 1.577–1.583. An RI of 1.620–1.637 cannot be beryl of any composition. This + distractor tests whether students will accept an unfounded "chromium raises RI" + explanation that sounds plausible but has no mineralogical basis. + - text: "Synthetic emerald (hydrothermal); hydrothermal emeralds have higher RI than natural beryl because the growth solution deposits additional silica in the lattice" + isCorrect: false + rationale: | + Hydrothermal synthetic emerald (Chatham, Biron, etc.) has essentially the same RI + as natural beryl — approximately 1.560–1.570 (slightly lower than natural due to + lower chromium and lower iron content relative to some natural emeralds). The RI + of hydrothermal synthetic emerald is never 1.620–1.637; that range is tourmaline. + Additional silica in the lattice is not how hydrothermal growth affects RI, and the + beryl structure does not incorporate free silica into the lattice above the + stoichiometric Be3Al2Si6O18 formula. +rationaleCorrect: | + Chrome tourmaline is the most important visual near-miss for fine emerald in the trade: + both are vivid green, both show dichroism, both can be found in similar size ranges + and cuts. The discrimination is straightforward with instruments — beryl RI 1.577–1.583, + tourmaline RI 1.620–1.640, a gap of approximately 0.04 units — but relies on the + gemmologist actually measuring the RI rather than accepting the seller's description. + This question represents a real commercial scenario: chrome tourmaline offered as + "Colombian emerald" has occurred in the trade. The SG difference (beryl 2.67–2.78 vs + tourmaline 3.06) provides additional confirmation. The lesson: always test, never trust + colour description alone for green stones of commercial significance. +difficulty: 3 +category: species +conceptTags: + - refractive-index + - specific-gravity + - pleochroism + - emerald + - beryl + - tourmaline + - uniaxial +sourceArticle: species/beryl +examRelevance: FGA-foundation +confusionPairs: + - green-stone-001 + - green-stone-003 + - green-stone-004 +similarTo: + - green-stone-001 + - green-stone-003 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 118–130, 173–178." diff --git a/src/content/questions/species/green-stone-003.yaml b/src/content/questions/species/green-stone-003.yaml new file mode 100644 index 0000000..f49816f --- /dev/null +++ b/src/content/questions/species/green-stone-003.yaml @@ -0,0 +1,76 @@ +id: green-stone-003 +stem: | + A bright yellowish-green faceted stone is tested. Results: RI — no shadow edge on + the standard refractometer (RI above contact-liquid limit); SG 3.84; polariscope — + uniformly dark, isotropic; LWUV — moderate orange fluorescence; strong dispersion + visible as bright coloured flashes when the stone is tilted under a desk lamp. + The stone is set in a ring described as "demantoid." What is the most likely identity, + and why does the above-range refractometer reading not indicate diamond? +type: mcq +options: + - text: "Demantoid garnet (andradite); cubic/isotropic with RI approximately 1.888 (above refractometer range), SG 3.82–3.85, highest dispersion of natural garnets (0.057) — the SG and dispersion confirm demantoid, not diamond (SG 3.52, inert LWUV)" + isCorrect: true + rationale: | + Demantoid (andradite garnet, Ca3Fe2(SiO4)3) is cubic and isotropic with RI + approximately 1.855–1.900 — well above the standard refractometer limit of ~1.81. + Its SG of 3.82–3.85 separates it from diamond (SG 3.52 — lighter, not heavier) + and confirms the garnet identification. The dispersion of 0.057 is the highest + of any natural garnet and produces vivid fire visible as coloured flashes. Diamond + has dispersion 0.044 (lower than demantoid) and is inert to LWUV. The orange LWUV + fluorescence is sometimes seen in demantoid containing chromium, though response + varies; diamond fluorescence is typically blue LWUV, not orange. + - text: "Tsavorite garnet (grossular); tsavorite has high RI above the refractometer range and bright green colour similar to demantoid" + isCorrect: false + rationale: | + Tsavorite (green grossular) has RI approximately 1.740–1.760, which IS within the + standard refractometer range — it gives a clear shadow edge in the 1.740–1.760 + region. Tsavorite does not exceed the refractometer limit. Additionally, tsavorite + SG is approximately 3.57–3.73, clearly lower than the measured 3.84. Tsavorite + dispersion is also lower than demantoid. A student who knows both are green garnets + might confuse them, but the measurable RI (tsavorite on scale, demantoid off scale) + and SG values separate them definitively. + - text: "Diamond; colourless diamonds can appear green from colouring inclusions, and the above-range RI and isotropic behaviour both confirm diamond" + isCorrect: false + rationale: | + Diamond SG is 3.52, clearly below the measured 3.84. Diamond dispersion is 0.044, + lower than the dramatic fire described (consistent with demantoid's 0.057). Diamond + is inert to LWUV (or shows blue LWUV fluorescence) — orange LWUV is not a diamond + response. While diamond is isotropic and above the refractometer range (confirming + those two observations), the SG eliminates it: at SG 3.84, the stone is denser + than diamond. The "no refractometer reading + isotropic" combination does not + uniquely identify diamond — any high-RI isotropic material (including garnets) fits. + The SG is the decisive test. +rationaleCorrect: | + Demantoid identification relies on the combination of: (1) above-range refractometer + (RI ~1.89, beyond contact-liquid limit); (2) cubic/isotropic on polariscope; (3) SG + 3.82–3.85 — heavier than diamond (3.52), which is a key discriminator; (4) high + dispersion producing vivid fire (0.057, higher than diamond's 0.044); (5) characteristic + "horsetail" inclusions under the microscope — byssolite/actinolite fibres radiating from + a chromite crystal, virtually diagnostic for demantoid. The absence of a refractometer + reading is often the first clue: always go to SG and microscopy next when the + refractometer gives no reading on a green isotropic stone. +difficulty: 4 +category: species +conceptTags: + - refractive-index + - specific-gravity + - isotropic + - dispersion + - demantoid + - diamond + - cubic-system +sourceArticle: species/garnet +examRelevance: FGA-foundation +confusionPairs: + - green-stone-001 + - green-stone-002 + - green-stone-004 +similarTo: + - green-stone-001 + - green-stone-004 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 165–172." diff --git a/src/content/questions/species/green-stone-004.yaml b/src/content/questions/species/green-stone-004.yaml new file mode 100644 index 0000000..668aae7 --- /dev/null +++ b/src/content/questions/species/green-stone-004.yaml @@ -0,0 +1,80 @@ +id: green-stone-004 +stem: | + A laboratory tests an emerald-green oval brilliant with an RI of 1.577–1.582 and + SG of 2.69. Under the microscope the gemmologist sees numerous two-phase fluid + inclusions, some irregular growth cavities, and short three-sided hollow channels + parallel to the crystal's c-axis — but no three-phase inclusions containing solid + crystals. LWUV shows a moderate red fluorescence. An assistant concludes: "Absence + of three-phase inclusions proves this is NOT Colombian emerald." Is the assistant + correct? +type: mcq +options: + - text: "The assistant is incorrect; three-phase inclusions are diagnostic FOR Colombian origin when present, but their absence does not exclude Colombia — not all Colombian emeralds contain them, and other tests (UV, RI, SG) remain consistent with Colombian beryl" + isCorrect: true + rationale: | + Three-phase inclusions are characteristic of Colombian emerald and, when present, + constitute very strong evidence for Colombian origin. However, their absence does + not exclude Colombia: not every Colombian emerald contains three-phase inclusions, + and some sections of a stone may lack them even when present elsewhere. Three-phase + inclusions are a positive indicator, not a required feature. The correct approach + is to state "no three-phase inclusions observed" in the report — a neutral + observation — while noting that the RI (1.577–1.582), SG (2.69), red LWUV + fluorescence, and growth-tube features are all consistent with Colombian beryl + (and other origins). Origin determination requires a weight of evidence. + - text: "The assistant is correct; three-phase inclusions are virtually diagnostic for Colombia, so their absence provides equal and opposite evidence that the stone is not Colombian" + isCorrect: false + rationale: | + Evidence in gemmology works asymmetrically: a diagnostic inclusion proves origin + when present, but its absence does not disprove that origin. Many confirmed + Colombian emeralds (verified by multiple laboratory methods including microscopy, + spectroscopy, and trace-element analysis) contain no visible three-phase inclusions + at standard loupe magnification. The student mistake is applying logical inversion + incorrectly: "three-phase = Colombia" does not invert to "no three-phase = not + Colombia." This logical error would lead to false negative reports on genuine + Colombian stones, with significant commercial consequences. + - text: "The assistant is correct, but for the wrong reason; the absence of three-phase inclusions proves the stone is synthetic emerald, which never contains three-phase inclusions" + isCorrect: false + rationale: | + Synthetic emeralds do not produce three-phase inclusions containing halite — + this is correct. However, many natural non-Colombian emeralds also lack three-phase + inclusions (Zambian, Brazilian, Ethiopian, Afghan emeralds have different inclusion + suites). The absence of three-phase inclusions does not prove synthetic origin; + it only reduces the probability of Colombian origin slightly. More importantly, + the properties described (moderate red LWUV, growth tubes, natural-looking two-phase + inclusions) are inconsistent with typical synthetic emerald suites, which tend to + show nail-head spicules, chevron patterns, or phenakite inclusions. Assuming synthetic + from the absence of one indicator is poor diagnostic practice. +rationaleCorrect: | + This question addresses a common logical error in gemmology: the confusion between + a positive indicator and a required indicator. Three-phase inclusions in emerald are + a positive diagnostic for Colombian origin when present, but their absence is not + a negative diagnostic. Colombian origin is established through a weight of evidence — + microscopy, UV fluorescence, RI, SG, and where necessary trace-element analysis (e.g. + LA-ICP-MS at a major laboratory). The red LWUV fluorescence (Cr-bearing beryl), RI + and SG within the beryl range, and natural inclusion types are all consistent with + Colombian origin; the laboratory report should note "origin consistent with Colombia, + no three-phase inclusions observed" rather than excluding Colombia on inclusion absence. +difficulty: 4 +category: species +conceptTags: + - three-phase-inclusion + - two-phase-inclusion + - emerald + - beryl + - fluorescence-LWUV + - refractive-index +sourceArticle: species/beryl +examRelevance: FGA-foundation +confusionPairs: + - green-stone-001 + - green-stone-002 + - green-stone-003 +similarTo: + - green-stone-001 + - green-stone-002 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 118–130." diff --git a/src/content/questions/species/green-stone-005.yaml b/src/content/questions/species/green-stone-005.yaml new file mode 100644 index 0000000..4b721d7 --- /dev/null +++ b/src/content/questions/species/green-stone-005.yaml @@ -0,0 +1,79 @@ +id: green-stone-005 +stem: | + A client brings a vivid green oval cabochon set in a silver ring. Spot RI reads + approximately 1.54. The stone appears homogeneous under the loupe with a waxy to + vitreous lustre, shows no inclusions, no birefringence effect, and SG approximately + 2.50. Under the Chelsea colour filter the stone appears green. LWUV inert. + A student suggests this could be a fine emerald. What instrument reading most + immediately eliminates emerald, and what is the more likely identity? +type: mcq +options: + - text: "RI approximately 1.54 immediately eliminates emerald (RI 1.577–1.583); the low RI combined with SG ~2.50 and featureless homogeneous appearance is consistent with green glass or green synthetic glass simulant" + isCorrect: true + rationale: | + Emerald (beryl) RI 1.577–1.583 with minimum value 1.577; the measured 1.54 is + approximately 0.037 below the minimum beryl value. This single reading eliminates + emerald absolutely — no beryl of any composition gives RI as low as 1.54. Green + glass RI can be approximately 1.50–1.55 depending on composition. SG ~2.50 is + consistent with light glass (beryl SG is 2.67–2.78, also higher). The + featureless, homogeneous interior (no growth features, no inclusions) is typical + of glass. The Chelsea filter observation (green under filter) does not confirm + emerald — Cr-bearing glass also appears green/red on the Chelsea filter, and many + other green materials appear green without chromium. + - text: "SG approximately 2.50 immediately eliminates emerald; green glass is confirmed because glass always reads 2.50 on hydrostatic weighing" + isCorrect: false + rationale: | + The SG is a secondary confirmatory observation, not the most immediate eliminator + in this case. SG of green glass varies considerably — from approximately 2.3 for + borosilicate to 4.5+ for high-lead glass — depending on composition. Stating that + glass "always reads 2.50" is incorrect. The RI reading is the most immediately + accessible and precise measurement: the refractometer is used first in the standard + workflow, and the 1.54 reading eliminates beryl with a 0.037-unit margin — far + beyond measurement error. The RI answer is both more immediate and more decisive + as a logical first step. + - text: "The Chelsea colour filter immediately eliminates emerald; emerald appears red under the Chelsea filter, and this green stone appearing green proves it is not Cr-bearing" + isCorrect: false + rationale: | + The Chelsea colour filter was designed to detect chromium by its absorption, making + Cr-bearing stones appear orange-red under the filter. However, the Chelsea filter + test is not binary: some Cr-bearing emeralds (especially vanadium-rich or iron-rich + varieties) may not respond strongly red, and some non-emerald green materials + (Cr-bearing glass, synthetic Cr-doped materials) appear red. A green response under + the Chelsea filter does not rule out Cr-bearing emerald — it is a screening tool, + not a definitive test. The statement is also partially factually wrong in suggesting + that "no red = not Cr-bearing": some emeralds with high Fe content show a muted + response. The RI is always the more reliable eliminator. +rationaleCorrect: | + Spot RI 1.54 is the critical reading here: it is below the minimum RI of any beryl + (1.577), eliminating emerald with certainty. The RI measurement is the most immediate + and instrument-based discriminator — it requires only seconds on the refractometer and + produces a value that either matches or does not match the target species. Green glass + simulants are common in cheap jewellery; the combination of low RI, low SG, featureless + interior, and homogeneous lustre is diagnostic of glass. The Chelsea colour filter is a + useful screening tool but subject to false positives and negatives. In a gemmological + laboratory, the refractometer finding always takes precedence over a Chelsea colour + filter observation when the two appear to conflict. +difficulty: 2 +category: species +conceptTags: + - refractive-index + - specific-gravity + - isotropic + - glass + - emerald + - beryl +sourceArticle: species/beryl +examRelevance: FGA-foundation +confusionPairs: + - green-stone-001 + - green-stone-002 + - green-stone-003 +similarTo: + - green-stone-002 + - red-stone-glass-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 118–130." diff --git a/src/content/questions/species/red-stone-garnet-001.yaml b/src/content/questions/species/red-stone-garnet-001.yaml new file mode 100644 index 0000000..7ded39b --- /dev/null +++ b/src/content/questions/species/red-stone-garnet-001.yaml @@ -0,0 +1,80 @@ +id: red-stone-garnet-001 +stem: | + A red faceted stone is tested in the laboratory. Results: single constant shadow + edge on the refractometer at approximately 1.75, SG 3.82, uniformly dark on the + polariscope except for occasional irregular patchy lightening that does not follow + the four-position pattern. No fluorescence under LWUV. The student initially suspects + ruby. Which identification is most consistent with these results, and what does the + irregular patchy polariscope response signify? +type: mcq +options: + - text: "Almandine garnet; SG 3.82 and RI approximately 1.75–1.77 are within almandine range; the patchy polariscope response is anomalous double refraction (ADR), common in cubic garnets due to strain" + isCorrect: true + rationale: | + Almandine (Fe-Al garnet) is cubic and isotropic, with RI typically 1.770–1.830 + and SG 3.9–4.2 — the measured 1.75 and 3.82 fall within the lower end of almandine + or could indicate a pyrope-almandine intermediate (rhodolite). The single constant + edge confirms isotropic behaviour. The patchy, irregular lightening on the polariscope + is anomalous double refraction (ADR): strain birefringence from growth stress or + compositional variation, appearing as irregular patches rather than systematic + four-position lighting. ADR is especially common in almandine and does not indicate + a doubly refractive mineral; it is a strain effect in a cubic crystal. + - text: "Ruby; the patchy polariscope response means the stone was examined down the optic axis, suppressing the normal uniaxial lighting pattern" + isCorrect: false + rationale: | + A uniaxial stone examined exactly down its optic axis (the c-axis) would appear + uniformly dark between crossed polars — it would not show patchy, irregular + lightening. Patchy lightening is specifically the pattern of ADR (anomalous double + refraction) in cubic minerals, not the pattern of a uniaxial stone viewed down + its optic axis. Moreover, ruby has RI 1.762–1.770 and SG 3.99–4.01; the measured + RI ~1.75 and SG 3.82 are both below corundum values. Two properties eliminate ruby; + the polariscope pattern provides additional confirmation of garnet. + - text: "Red glass; glass can give a single constant shadow edge and irregular polariscope patterns; the SG of 3.82 is consistent with lead glass used as a ruby simulant" + isCorrect: false + rationale: | + High-lead glass (strass) used as a ruby simulant can achieve SG values up to + approximately 4.5–5.0 and RI up to approximately 1.70–1.75 depending on composition. + SG 3.82 is plausible for heavy glass, but glass would give a perfectly uniform dark + field on the polariscope (it is amorphous and isotropic) without the patchy ADR + that characterises strained cubic crystals. The patchy lightening is the key + distinction: ADR in garnet is a structured, crystal-related strain effect; glass + shows true isotropy. Under the microscope, glass also shows swirl marks, gas + bubbles, and lacks crystalline inclusions, which would confirm the distinction. +rationaleCorrect: | + Almandine garnet (or pyrope-almandine rhodolite) is the correct identification. + The single constant shadow edge confirms isotropic (cubic) behaviour; the RI ~1.75 + and SG 3.82 are consistent with almandine or a pyrope-rich intermediate. The patchy + polariscope response is anomalous double refraction (ADR) — a strain birefringence + effect common in cubic garnets, particularly almandine, from internal stresses during + rapid growth or compositional variation in the lattice. ADR looks like irregular + patches of light and dark rather than the systematic four-fold lightening of a doubly + refractive gem. The absence of LWUV fluorescence is typical for iron-bearing garnet. + Distinguishing ADR from true double refraction is a key FGA practical skill: rotate + the stone slowly and observe whether the light/dark pattern is systematic (four positions + = DR) or irregular and wandering (ADR = garnet). +difficulty: 3 +category: species +conceptTags: + - refractive-index + - specific-gravity + - isotropic + - anomalous-double-refraction + - almandine + - pyrope + - ruby + - optic-character +sourceArticle: species/garnet +examRelevance: FGA-foundation +confusionPairs: + - corundum-vs-spinel-001 + - corundum-vs-spinel-002 + - corundum-vs-spinel-003 +similarTo: + - corundum-vs-spinel-001 + - corundum-vs-spinel-002 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 102–115, 165–172." diff --git a/src/content/questions/species/red-stone-glass-001.yaml b/src/content/questions/species/red-stone-glass-001.yaml new file mode 100644 index 0000000..b31fd7a --- /dev/null +++ b/src/content/questions/species/red-stone-glass-001.yaml @@ -0,0 +1,77 @@ +id: red-stone-glass-001 +stem: | + A gemmologist receives a red faceted oval stone mounted in a gold-coloured foiled + setting. The stone cannot be removed from the setting, preventing hydrostatic SG + measurement. Testing in the setting reveals: spot RI approximately 1.59, uniformly + dark on the polariscope throughout rotation, no fluorescence under LWUV or SWUV. + The seller describes the piece as a "Georgian ruby ring." What is the most likely + identification, and what observation about the setting and RI confirms this? +type: mcq +options: + - text: "Red glass; RI ~1.59 is well below any corundum or garnet value and is consistent with common silica glass; foiled settings are diagnostic of glass stones used in antique jewellery before synthetic gem production" + isCorrect: true + rationale: | + RI approximately 1.59 is far below ruby (1.762–1.770), spinel (1.712–1.720), and + almandine garnet (1.770–1.830) — all three cannot produce this reading. The value + 1.59 falls within the range for silicate glass (typically 1.44–1.70 depending on + composition). Foiled settings — metal foil behind the stone to enhance brightness + — were a standard technique for glass stones in Georgian and early Victorian + jewellery before synthetic stones became available. The isotropic polariscope + result is consistent with amorphous glass. The combination of low RI, isotropic + behaviour, and foiled setting is diagnostic of red glass paste in Georgian + jewellery. + - text: "Natural ruby; Georgian jewellers often cut rubies with foiled settings to enhance the colour, and heat treatment has reduced the RI to 1.59" + isCorrect: false + rationale: | + Heat treatment does not alter the RI or crystal structure of corundum. Ruby RI is + determined by the Al2O3 crystal lattice and cannot be changed by any thermal + treatment to reach 1.59 — a difference of over 0.17 from corundum's 1.762. Foiled + settings were not typically used for genuine rubies in Georgian jewellery; they + were used for glass, paste, and composite stones to improve brilliance where the + material's own lustre was insufficient. A genuine Georgian ruby in a foiled setting + would be highly unusual and would still give RI 1.762–1.770. + - text: "Synthetic ruby (flame fusion); Verneuil synthesis was available to Georgian craftsmen and produces lower RI than natural ruby" + isCorrect: false + rationale: | + The Verneuil flame-fusion process was developed by Auguste Verneuil and became + commercially available in 1902 — well after the Georgian era (approximately + 1714–1830). Georgian jewellers did not have access to synthetic ruby. More + importantly, Verneuil synthetic ruby has exactly the same RI as natural ruby + (1.762–1.770) because it is chemically identical Al2O3. No form of synthetic + corundum produces RI 1.59. This distractor tests both historical knowledge + (when synthetic ruby became available) and basic property knowledge (synthetic + ruby RI = natural ruby RI). +rationaleCorrect: | + Red glass paste in foiled settings is one of the most common finds in Georgian antique + jewellery. The RI of approximately 1.59 is approximately 0.17 units below corundum + and is consistent with lead-free silicate glass. The foiled setting is a key contextual + clue: before synthetic gems were available, jewellers used glass paste (pâte de verre) + as a colour-true simulant, backing it with reflective metal foil to compensate for + the lower lustre. This technique was perfected by the Strass/Stras workshops in + 18th-century France and widely used in Georgian jewellery. The gemmologist should + report: red glass paste in foil-back setting, consistent with 18th–early 19th century + production; no evidence of ruby. This has significant implications for the piece's + value and insurance. +difficulty: 3 +category: species +conceptTags: + - refractive-index + - isotropic + - glass + - ruby + - corundum +sourceArticle: species/corundum +examRelevance: FGA-foundation +confusionPairs: + - corundum-vs-spinel-001 + - corundum-vs-spinel-002 + - red-stone-garnet-001 +similarTo: + - red-stone-garnet-001 + - corundum-vs-spinel-002 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 102–115." From a8bdf8559f18f284ff5186844dc1ce3d06d07e8f Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 11:54:50 +0700 Subject: [PATCH 14/16] feat(study-content): add 10 identification questions on inclusion recognition Silk dissolution and treatment detection (silk-001, silk-002), natural fingerprint vs flux fingerprint (fingerprint-001), fingerprint vs fissure discrimination (fissure-001), three-phase inclusions and Colombian origin (three-phase-001), growth tubes in tourmaline (growth-tube-001), zircon halos and heat treatment (zircon-halo-001), negative crystals in spinel (negative-crystal-001), two-phase inclusions and synthetic discrimination (two-phase-001), demantoid horsetail inclusions (horsetail-001). --- .../inclusion-fingerprint-001.yaml | 79 ++++++++++++++++++ .../identification/inclusion-fissure-001.yaml | 80 ++++++++++++++++++ .../inclusion-growth-tube-001.yaml | 78 ++++++++++++++++++ .../inclusion-horsetail-001.yaml | 80 ++++++++++++++++++ .../inclusion-negative-crystal-001.yaml | 80 ++++++++++++++++++ .../identification/inclusion-silk-001.yaml | 76 +++++++++++++++++ .../identification/inclusion-silk-002.yaml | 74 +++++++++++++++++ .../inclusion-three-phase-001.yaml | 80 ++++++++++++++++++ .../inclusion-two-phase-001.yaml | 78 ++++++++++++++++++ .../inclusion-zircon-halo-001.yaml | 82 +++++++++++++++++++ 10 files changed, 787 insertions(+) create mode 100644 src/content/questions/identification/inclusion-fingerprint-001.yaml create mode 100644 src/content/questions/identification/inclusion-fissure-001.yaml create mode 100644 src/content/questions/identification/inclusion-growth-tube-001.yaml create mode 100644 src/content/questions/identification/inclusion-horsetail-001.yaml create mode 100644 src/content/questions/identification/inclusion-negative-crystal-001.yaml create mode 100644 src/content/questions/identification/inclusion-silk-001.yaml create mode 100644 src/content/questions/identification/inclusion-silk-002.yaml create mode 100644 src/content/questions/identification/inclusion-three-phase-001.yaml create mode 100644 src/content/questions/identification/inclusion-two-phase-001.yaml create mode 100644 src/content/questions/identification/inclusion-zircon-halo-001.yaml diff --git a/src/content/questions/identification/inclusion-fingerprint-001.yaml b/src/content/questions/identification/inclusion-fingerprint-001.yaml new file mode 100644 index 0000000..8d06ff1 --- /dev/null +++ b/src/content/questions/identification/inclusion-fingerprint-001.yaml @@ -0,0 +1,79 @@ +id: inclusion-fingerprint-001 +stem: | + Under the microscope at 40× magnification, a gemmologist examines a pale green + sapphire and observes a complex internal feature: a lace-like network of tiny + fluid droplets and channels arranged in a pattern resembling a human fingerprint. + The pattern is sharp and well-defined, with fine detail visible at the edges of + the feature. A second stone, also pale sapphire, shows a similar network pattern + but the edges of the fluid droplets are fuzzy and indistinct, with a cottony or + cloud-like appearance. What do these two different fingerprint appearances indicate? +type: mcq +options: + - text: "Stone 1 (sharp, well-defined network) has a natural fingerprint from a healed fracture; Stone 2 (fuzzy, cotton-like pattern) shows flux fingerprints — the different texture indicates heat treatment in the presence of flux material" + isCorrect: true + rationale: | + Natural fingerprint inclusions form when a fracture heals around residual fluid, + trapping droplets in a regular lace-like pattern. The healing process in natural + conditions (geological time, groundwater or metamorphic fluid) produces sharp, + well-defined droplet edges because the healing is complete and the fluid droplets + are clearly bounded. Flux fingerprints — formed when fractures are healed during + high-temperature treatment in the presence of a flux (e.g. borax) — show a + distinctly different texture: fuzzy, cottony, or cloud-like edges because the + molten flux material partially fills the fracture and leaves an irregular, + glassy residue with less well-defined droplet boundaries. + - text: "Stone 1 is a synthetic sapphire with curved growth striae producing the fingerprint pattern; Stone 2 is natural with a partially healed fissure" + isCorrect: false + rationale: | + Curved growth striae in Verneuil synthetic corundum are fine curved lines that + extend across the stone parallel to the growth front — they do not form lace-like + droplet networks resembling fingerprints. Fingerprint inclusions in both natural + and flux-treated corundums form from healed fractures containing fluid; their + origin is mechanical (a fracture that healed), not from the initial growth process. + Stone 2's fuzzy appearance is not from a "partially healed fissure" — a partially + healed fracture would show clear fluid or open fracture areas, not a cottony + texture throughout the fingerprint. + - text: "Both fingerprints are identical in nature; the apparent texture difference is an artefact of different illumination angles during observation and has no diagnostic significance" + isCorrect: false + rationale: | + The textural difference between natural fingerprints (sharp, well-defined droplets) + and flux fingerprints (fuzzy, cottony) is a genuine physical difference in the + nature of the included material — not an illumination artefact. This distinction + is documented in the gemmological literature as a treatment indicator. Experienced + gemmologists deliberately use both darkfield and fibre-optic illumination to + confirm the textures are stable across lighting conditions. Claiming the difference + is merely an observation artefact would lead to a missed treatment detection, + a serious professional error. +rationaleCorrect: | + The natural fingerprint vs. flux fingerprint distinction is one of the most important + treatment detection observations in sapphire and ruby examination. Natural fingerprints + arise from geological healing of fractures; the droplet edges are crisp because they + are mineral-fluid interfaces. Flux fingerprints arise during heat treatment with + borax or other fluxes; the flux melt partially fills the fracture, producing an + irregular glassy residue with fuzzy boundaries. The difference is reproducible across + lighting conditions and magnifications. A laboratory report noting "flux fingerprints + observed" means heat treatment with a flux material is indicated — this affects + disclosure requirements in the trade. Never dismiss a textural difference in fingerprint + inclusions as a lighting artefact without confirming it from multiple angles. +difficulty: 4 +category: identification +conceptTags: + - fingerprint-inclusion + - flux-fingerprint + - heat-treatment + - corundum + - natural-vs-synthetic +sourceArticle: identification/inclusions/feathers +examRelevance: FGA-foundation +confusionPairs: + - inclusion-silk-001 + - inclusion-fissure-001 + - inclusion-three-phase-001 +similarTo: + - inclusion-silk-001 + - inclusion-fissure-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 233–248." diff --git a/src/content/questions/identification/inclusion-fissure-001.yaml b/src/content/questions/identification/inclusion-fissure-001.yaml new file mode 100644 index 0000000..f6da439 --- /dev/null +++ b/src/content/questions/identification/inclusion-fissure-001.yaml @@ -0,0 +1,80 @@ +id: inclusion-fissure-001 +stem: | + A gemmologist is asked to distinguish between a fingerprint inclusion and a fissure + (open fracture reaching the surface) in a blue sapphire. Both features have a similar + irregular, planar appearance when first viewed under the microscope. Which observation + most reliably separates a healed internal fingerprint from an unhealed surface-reaching + fissure, and why does this distinction matter for a jewellery appraisal? +type: mcq +options: + - text: "A healed fingerprint shows a regular network of tiny fluid droplets and remains internal with no light leakage from the surface; a fissure shows continuity to the surface (visible as a bright line under reflected light) and often shows iridescence from thin-film interference or allows contact liquid to enter the fracture during refractometer testing" + isCorrect: true + rationale: | + The key discriminators are: (1) internal vs. surface-reaching — a fingerprint is + fully enclosed within the gem; under fibre-optic or reflected light, a fissure + shows bright light leakage where it reaches the surface; (2) iridescence — open + or partially open fissures show interference colours (iridescence) from a thin + air gap within the fracture; healed fingerprints have no air gap and show no + iridescence; (3) contact liquid test — when testing on the refractometer, contact + liquid can enter a surface-reaching fissure and may affect the reading; (4) the + network pattern — a true fingerprint shows the characteristic lace-like droplet + pattern; a fracture may show more linear, irregular planes without the droplet + network. The distinction matters for appraisal: a surface-reaching fissure reduces + durability and value; an internal fingerprint is less concerning for durability. + - text: "Rotate the stone under crossed polars; a fingerprint shows birefringence effects, while a fissure does not" + isCorrect: false + rationale: | + Neither a fingerprint inclusion nor a fissure shows intrinsic birefringence effects + between crossed polars — these are not inherently birefringent features. The host + gem's birefringence is what is observed on the polariscope, not the inclusion type. + A birefringent host (e.g. sapphire) shows birefringence across the whole stone, + regardless of whether it contains fingerprints or fissures. Using the polariscope + to distinguish fingerprint from fissure is not standard practice and would not + produce the described result. The correct discrimination method is reflected-light + and surface-examination microscopy. + - text: "A fingerprint absorbs UV light differently from a fissure; UV fluorescence imaging distinguishes the two" + isCorrect: false + rationale: | + UV fluorescence examines the distribution of fluorescent material in the gem. + Open fissures can sometimes show enhanced fluorescence or fluorescent filler + material (in treated stones), but a simple unfilled fissure in a non-UV-fluorescent + host sapphire would show neither enhanced nor reduced fluorescence compared to + an internal fingerprint. UV fluorescence is useful for detecting fracture filling + (e.g. glass filling in fractures fluorescing blue under SWUV) but is not a reliable + primary method for distinguishing healed fingerprints from unfilled fissures. + Reflected-light surface examination is the direct and reliable approach. +rationaleCorrect: | + The fingerprint/fissure distinction is fundamental to gem appraisal because the two + features have very different implications for durability and value. A fully internal + fingerprint does not reach the surface — it cannot be a point of entry for cleaning + solutions, setting prongs, or mechanical stress propagation. A surface-reaching fissure + is a vulnerability: it can extend with use, trap foreign material, and reduce the gem's + useful life in jewellery. The practical tests are: (1) low-power reflected light scanning + the entire girdle and facets for bright lines where fractures emerge; (2) fibre-optic + illumination to trace the plane of the feature to its extent; (3) looking for iridescence + in the body of the feature (open fracture = iridescence; healed = no iridescence). + A thorough microscopic examination at both low and high magnification is required before + an appraisal conclusion can be stated. +difficulty: 3 +category: identification +conceptTags: + - fingerprint-inclusion + - fracture + - cleavage + - corundum + - sapphire +sourceArticle: identification/inclusions/feathers +examRelevance: FGA-foundation +confusionPairs: + - inclusion-fingerprint-001 + - inclusion-silk-001 + - inclusion-growth-tube-001 +similarTo: + - inclusion-fingerprint-001 + - inclusion-three-phase-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 233–248." diff --git a/src/content/questions/identification/inclusion-growth-tube-001.yaml b/src/content/questions/identification/inclusion-growth-tube-001.yaml new file mode 100644 index 0000000..6f0dfc3 --- /dev/null +++ b/src/content/questions/identification/inclusion-growth-tube-001.yaml @@ -0,0 +1,78 @@ +id: inclusion-growth-tube-001 +stem: | + A gemmologist examines a green tourmaline under the microscope and observes numerous + fine, parallel, hollow or fluid-filled channels running the length of the stone, + all aligned parallel to the long axis (c-axis) of the crystal. Some channels appear + as bright lines in reflected light; others contain liquid with a visible mobile + bubble. A colleague says these are "rutile silk needles." What is the correct + identification of these features, and why does their presence support natural origin? +type: mcq +options: + - text: "Growth tubes, not rutile silk; growth tubes in tourmaline are hollow or fluid-filled channels parallel to the c-axis, formed during crystal growth — not exsolved needles; their consistent orientation and natural formation process support natural rather than synthetic origin" + isCorrect: true + rationale: | + Growth tubes are a characteristic natural inclusion type in tourmaline, formed + parallel to the c-axis during crystal growth as the growing face traps channels + of surrounding fluid or grows around fine particles. They are not rutile TiO2 + needles — they are structural growth features. Rutile silk is specific to corundum + (and some garnets) and exsolves from the host lattice as the crystal cools; + it does not occur in tourmaline. The two are easily distinguished: silk needles + in corundum are solid TiO2, oriented at 60° intersections; tourmaline growth tubes + are hollow or fluid-filled, parallel to c, and do not intersect at 60°. The + presence of growth tubes supports natural origin because synthetic tourmaline is + extremely rarely produced commercially and does not contain these features. + - text: "Rutile silk; tourmaline also exsolves rutile from its lattice during cooling, producing needles parallel to the c-axis in the same way as corundum" + isCorrect: false + rationale: | + Rutile silk (TiO2 exsolution) is primarily known in corundum and to a lesser + extent in some garnets (almandine). It does not occur in tourmaline via the same + exsolution mechanism. Tourmaline's boron-silicate lattice chemistry does not + support titanium exsolution as rutile in the way corundum's aluminium oxide + lattice does. What the student calls "rutile silk" in tourmaline are growth tubes — + physically distinct features (hollow or fluid-filled, parallel to c-axis, formed + during growth, not on cooling). Mislabelling growth tubes as silk is a + misconception that would lead to incorrect diagnostic reports. + - text: "Cleavage traces; tourmaline has perfect prismatic cleavage parallel to c, producing the parallel lines observed" + isCorrect: false + rationale: | + Tourmaline has poor to indistinct prismatic cleavage — not perfect cleavage. + What cleavage exists is rarely expressed as clean, regular parallel lines inside + a polished gem. More importantly, cleavage traces would appear as flat, planar + reflective surfaces cutting through the stone, not as fine channels containing + fluid with visible mobile bubbles. The presence of a mobile bubble within the + feature is definitive evidence of a fluid-filled channel — a growth tube — + not a cleavage trace. Cleavage traces do not contain liquid or bubbles. +rationaleCorrect: | + Growth tubes are one of the most characteristic and commonly observed inclusions in + tourmaline. They form during crystal growth when the advancing crystal face traps + channels of surrounding fluid or mineral particles. In finished gems, growth tubes + appear as fine parallel channels along the c-axis — some hollow and reflecting light, + others filled with residual fluid containing a mobile gas bubble (making them + two-phase inclusions within a growth tube). Their presence confirms natural origin: + synthetic tourmaline is essentially non-existent commercially (tourmaline synthesis + is technically challenging and economically impractical at gem quality). The + distinction from rutile silk is critical: silk is solid TiO2 in corundum at 60° + intersections; growth tubes are hollow/fluid channels in tourmaline parallel to c-axis. +difficulty: 2 +category: identification +conceptTags: + - growth-tube + - two-phase-inclusion + - needle-inclusion + - tourmaline + - natural-vs-synthetic +sourceArticle: identification/inclusions/fluid-inclusions +examRelevance: FGA-foundation +confusionPairs: + - inclusion-silk-001 + - inclusion-fissure-001 + - inclusion-fingerprint-001 +similarTo: + - inclusion-silk-001 + - inclusion-silk-002 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 233–248." diff --git a/src/content/questions/identification/inclusion-horsetail-001.yaml b/src/content/questions/identification/inclusion-horsetail-001.yaml new file mode 100644 index 0000000..8c3092b --- /dev/null +++ b/src/content/questions/identification/inclusion-horsetail-001.yaml @@ -0,0 +1,80 @@ +id: inclusion-horsetail-001 +stem: | + Under the microscope at 20× magnification, a gemmologist examines a vivid yellowish-green + faceted gemstone and identifies a characteristic inclusion: a dense cluster of fine, + curved, golden-brown fibres radiating outward from a central dark crystal, resembling + a horse's tail. The stone has no shadow edge on the standard refractometer and SG of + approximately 3.84. Which species does this inclusion type identify, what is the + fibrous mineral within it, and why is SG the key confirmatory test for this species? +type: mcq +options: + - text: "Demantoid garnet (andradite); the 'horsetail' inclusion is byssolite (actinolite, a fibrous amphibole) radiating from a chromite crystal; SG ~3.84 confirms andradite and eliminates other green gems above the refractometer range" + isCorrect: true + rationale: | + The horsetail inclusion is one of the most famous and visually distinctive + inclusions in gemmology. It consists of curved, golden-brown or greenish-brown + fibres of byssolite (a fibrous variety of actinolite, calcium magnesium iron + amphibole) radiating from a central chromite (FeCr2O4) crystal. This combination + is virtually diagnostic for demantoid garnet from the Ural Mountain deposits in + Russia. The SG of 3.84 is critical because demantoid (andradite, Ca3Fe2(SiO4)3) + has RI approximately 1.888, above the standard refractometer range — no shadow + edge is visible. SG distinguishes demantoid (3.82–3.85) from tsavorite (3.57–3.73), + green tourmaline (3.06), and emerald (2.67–2.78), all of which have a refractometer + reading. + - text: "Tsavorite garnet (grossular); tsavorite shows fibrous actinolite inclusions in cluster form as a result of its metamorphic origin in Kenya" + isCorrect: false + rationale: | + Tsavorite (green grossular garnet) does not show horsetail inclusions. Tsavorite + from Kenya forms in metasomatic calc-silicate rocks and contains typical inclusions + such as apatite, graphite, and diopside — not fibrous actinolite halos from chromite. + The horsetail inclusion is specific to demantoid from the Ural deposits (and + occasionally other andradite localities). Additionally, tsavorite RI is approximately + 1.740–1.760, within the standard refractometer range — it would give a visible + shadow edge, contradicting the "no shadow edge" observation. Confusing tsavorite + and demantoid is a common error because both are valuable green garnets. + - text: "Chrome tourmaline; chrome tourmaline contains fibrous inclusions radiating from a chromite crystal in characteristic horsetail patterns" + isCorrect: false + rationale: | + Chrome tourmaline does not show horsetail inclusions. Tourmaline inclusions include + growth tubes parallel to the c-axis, fluid inclusions, and occasionally mineral + crystals, but not the characteristic radial byssolite fibres from a chromite centre + that define the horsetail. Furthermore, chrome tourmaline RI is 1.620–1.640 — it + gives a clear shadow edge on the refractometer. The "no shadow edge" observation + eliminates tourmaline before microscopy even begins. A student choosing tourmaline + may be associating "chrome" and "fibrous inclusions" without knowing the specific + inclusion morphology of each species. +rationaleCorrect: | + The horsetail inclusion — byssolite fibres radiating from a chromite crystal — is + one of the most celebrated diagnostic features in gemmology and is virtually + pathognomonic for Russian demantoid garnet. Demantoid from the Ural Mountains + almost always shows horsetails; demantoid from other localities (Madagascar, + Namibia, Iran) may or may not show them. The combination of: no refractometer + reading (RI ~1.89, above contact-liquid limit), SG 3.82–3.85, isotropic polariscope + (cubic), high dispersion (fire), and horsetail inclusions is definitively demantoid + of probable Russian Ural origin. SG is the essential test when RI is unavailable: + demantoid heavier than diamond (3.52) and tsavorite (3.57–3.73), lighter than + spessartine (3.8–4.2 variable). Know these SG anchors. +difficulty: 4 +category: identification +conceptTags: + - needle-inclusion + - demantoid + - natural-vs-synthetic + - specific-gravity + - refractive-index + - cubic-system +sourceArticle: identification/inclusions/solid-inclusions +examRelevance: FGA-foundation +confusionPairs: + - inclusion-zircon-halo-001 + - inclusion-three-phase-001 + - green-stone-003 +similarTo: + - inclusion-zircon-halo-001 + - green-stone-003 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 233–248." diff --git a/src/content/questions/identification/inclusion-negative-crystal-001.yaml b/src/content/questions/identification/inclusion-negative-crystal-001.yaml new file mode 100644 index 0000000..dce4b41 --- /dev/null +++ b/src/content/questions/identification/inclusion-negative-crystal-001.yaml @@ -0,0 +1,80 @@ +id: inclusion-negative-crystal-001 +stem: | + A gemmologist examines a blue spinel under the microscope and observes small, regular, + multi-faceted cavities within the stone. Each cavity has the geometric form of a + regular octahedron — eight triangular faces meeting at precise angles. The cavities + appear either empty (filled with air, showing total internal reflection at their faces) + or partially filled with liquid. A student labels these as "diamond-shaped crystal + inclusions." What is the correct term for these features, and why do they have an + octahedral shape? +type: mcq +options: + - text: "Negative crystals; the cavities have an octahedral form because spinel (cubic, m3̄m point group) has octahedral habit as its characteristic crystal form — the void takes the shape of the host crystal's symmetry" + isCorrect: true + rationale: | + Negative crystals are fluid-filled or empty cavities that have the geometric shape + of the host mineral's own crystal form. Spinel is cubic (point group m3̄m) and + crystallises characteristically as octahedra — so cavities that formed by + dissolution or incomplete crystal growth within spinel take on the octahedral + habit. The octahedral shape is an expression of the host crystal's symmetry and + bond strengths — the surfaces of the cavity form along the {111} crystal faces, + which represent the low-surface-energy planes of the cubic structure. Calling + them "diamond-shaped" is colloquially used by non-specialists but is incorrect + gemmological terminology. + - text: "Fluid inclusions; the octahedral shape indicates the trapped fluid crystallised into an octahedral halite crystal, similar to three-phase inclusions in emerald" + isCorrect: false + rationale: | + Halite (NaCl) crystallises in the cubic system with a cube habit — cubic (isometric) + halite forms cubes, not octahedra. More importantly, what the student describes + are not solid halite crystals but geometric voids (cavities) with the spinel crystal + form. The distinction between a negative crystal (a void with host-crystal form) + and a three-phase inclusion (a cavity containing liquid + gas + solid daughter + crystal) is fundamental. Confusing the octahedral shape with "crystallised halite" + misidentifies both the shape (cube vs. octahedron) and the nature of the feature + (solid daughter crystal vs. an empty/fluid-filled void). + - text: "Cleavage voids; the octahedral faces of the cavities are {111} cleavage planes in spinel, which have opened up to form empty octahedral cleavage-bounded cavities" + isCorrect: false + rationale: | + Spinel does have imperfect octahedral cleavage on {111}, but cleavage voids would + appear as flat, irregular planar gaps rather than perfect multi-faced enclosed + cavities. True octahedral negative crystals are enclosed geometric forms with all + eight faces meeting cleanly — they are not cleavage features, which are planar and + open-ended. The distinction is important: cleavage affects the gem's durability and + is usually visible as flat planes extending to or toward the surface; negative + crystals are enclosed internal voids with no connection to the surface. +rationaleCorrect: | + Negative crystals in spinel are one of the classic microscopic features of this species + and are also seen in other cubic gems (diamond, garnet) and in non-cubic gems where the + void shape reflects the host crystal symmetry. In spinel (cubic, octahedral habit), + negative crystals take the octahedral form. They may be empty (showing total internal + reflection at the {111} faces, giving them a mirror-bright appearance) or partially + filled with residual fluid. Their presence is a natural origin indicator — synthetic + spinel (flame fusion or hydrothermal) rarely if ever shows perfect natural negative + crystals of this kind, though synthetic material may show other growth features. The + term "negative crystal" is standard in gemmological and mineralogical literature; + students should recognise it and be able to explain the shape relationship to the host + crystal's habit. +difficulty: 3 +category: identification +conceptTags: + - negative-crystal + - two-phase-inclusion + - spinel + - cubic-system + - crystal-habit + - natural-vs-synthetic +sourceArticle: identification/inclusions/fluid-inclusions +examRelevance: FGA-foundation +confusionPairs: + - inclusion-three-phase-001 + - inclusion-growth-tube-001 + - inclusion-fissure-001 +similarTo: + - inclusion-three-phase-001 + - inclusion-fissure-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 233–248." diff --git a/src/content/questions/identification/inclusion-silk-001.yaml b/src/content/questions/identification/inclusion-silk-001.yaml new file mode 100644 index 0000000..a9f74d6 --- /dev/null +++ b/src/content/questions/identification/inclusion-silk-001.yaml @@ -0,0 +1,76 @@ +id: inclusion-silk-001 +stem: | + Under darkfield illumination at 30× magnification, a gemmologist examines a red + corundum cabochon. They observe extremely fine, bright, needle-like inclusions + arranged in three intersecting directions, meeting at approximately 60° angles + throughout the stone. The needles are short and appear slightly interrupted in + places, as if broken into dotted segments. The stone has been described by the + seller as "untreated Burmese ruby." What do the broken, dotted needles indicate + about the stone's treatment history? +type: mcq +options: + - text: "The interrupted (dotted) needles indicate partial heat treatment; intact silk suggests unheated, fully dissolved silk suggests high-heat treatment, and broken or dotted silk is diagnostic of moderate-heat treatment that partially dissolved the rutile needles" + isCorrect: true + rationale: | + Rutile silk in corundum is progressively altered by heat treatment. At temperatures + used in standard ruby enhancement (typically 1200–1800°C), rutile needles begin to + dissolve back into the corundum lattice (TiO2 re-entering the Al2O3 structure). + The dissolution starts at the ends and along the length of the needles, producing + an interrupted or "dotted" appearance. This pattern — segments of needle with + gaps between them — is a well-established indicator of moderate heat treatment. + The seller's claim of "untreated" is contradicted by the dotted silk observation. + An unheated ruby should show intact, sharp-edged silk needles. + - text: "The broken silk needles indicate natural parting of the corundum, which fractures silk as it forms; this is a natural feature not related to treatment" + isCorrect: false + rationale: | + Natural parting in corundum (along twin planes) occurs at a scale visible as flat + reflective surfaces, not as a breakdown of individual silk needles within the bulk + of the crystal. Parting does not selectively interrupt rutile needles mid-length + while leaving the orientation intact; it would produce a different observation — + a flat plane cutting across the stone. The dotted, segmented appearance of silk + needles is specifically associated with thermal dissolution of rutile in the + literature and is not explained by natural parting mechanics. + - text: "The needles are actually growth tubes from the original crystal, and the interrupted appearance is from the growth front stopping and restarting during crystallisation" + isCorrect: false + rationale: | + Growth tubes (hollow or fluid-filled channels parallel to the c-axis in some gem + species) are a different inclusion type from rutile silk. Rutile silk is solid TiO2 + crystallites, not hollow tubes, and they form by exsolution from the corundum lattice + on cooling — not during primary growth. The intersecting pattern at 60° is specific + to exsolved rutile silk (the three crystallographic directions of the trigonal + corundum lattice). Growth tubes in tourmaline or aquamarine are parallel to c-axis + only and do not form intersecting networks. Confusing growth tubes with silk + represents a fundamental miscategorisation of inclusion types. +rationaleCorrect: | + The thermal dissolution sequence of rutile silk in corundum is one of the most important + treatment indicators in ruby and sapphire examination. Three states are recognised: + (1) intact, sharp needles = likely unheated; (2) broken/dotted needles = moderate heat + treatment (partial dissolution); (3) no silk = high-heat treatment (complete dissolution) + or the stone never had silk. The presence of dotted silk is often combined with other + heat-treatment indicators (e.g. healed fissures, altered fingerprints, rutile particles + re-precipitated on healed surfaces). Reporting "dotted silk observed — heat treatment + cannot be excluded" is the correct wording. The seller's "untreated" claim is + inconsistent with the microscopic evidence. +difficulty: 3 +category: identification +conceptTags: + - silk-inclusion + - heat-treatment + - corundum + - ruby + - needle-inclusion +sourceArticle: identification/inclusions/silk +examRelevance: FGA-foundation +confusionPairs: + - inclusion-silk-002 + - inclusion-fingerprint-001 + - inclusion-three-phase-001 +similarTo: + - inclusion-silk-002 + - inclusion-fingerprint-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 233–248." diff --git a/src/content/questions/identification/inclusion-silk-002.yaml b/src/content/questions/identification/inclusion-silk-002.yaml new file mode 100644 index 0000000..700e999 --- /dev/null +++ b/src/content/questions/identification/inclusion-silk-002.yaml @@ -0,0 +1,74 @@ +id: inclusion-silk-002 +stem: | + A student examines two blue sapphire cabochons from different origins under the + microscope. Stone A (described as Sri Lankan/Ceylon) shows long, prominent, clearly + separated rutile needles extending across the full field of view and intersecting + at 60°. Stone B (described as Burmese, Mogok) shows very fine, short, densely packed + needles that give the stone a soft, slightly hazy appearance. Which description + matches which origin, and what trade term is used for Stone B's appearance? +type: mcq +options: + - text: "Stone A is Sri Lankan (long, prominent needles); Stone B is Burmese Mogok (fine, dense, short needles producing the 'velvety' or 'sleepy' appearance valued in fine Burmese ruby and sapphire)" + isCorrect: true + rationale: | + Sri Lankan (Ceylon) corundums typically show longer, more widely spaced, prominent + rutile needles that are easily visible individually under the microscope and often + produce strong asterism in asteriated stones. Burmese Mogok corundums show much + finer, denser, shorter needles — so fine that they scatter light to produce a soft, + slightly diffused glow called "velvety" or "sleepy" appearance. This velvet effect + is associated with the finest Burmese stones and is one of the visual qualities + gemmologists look for in premium rubies and sapphires. The silk characteristics + are an origin indicator, though not conclusive on their own. + - text: "Stone A is Burmese Mogok (long needles for maximum asterism); Stone B is Sri Lankan (fine needles for clarity)" + isCorrect: false + rationale: | + The origin descriptions are reversed. Mogok Burmese corundums are known for their + fine, delicate silk — not long, prominent needles. Long, clearly separated needles + producing strong asterism are more characteristic of Sri Lankan material. Asterism + in Burmese star rubies does occur, but the silk type that produces the prized + "velvety" quality in non-asteriated Mogok stones is fine and dense. A student + who memorises "Burma = fine silk" and "Ceylon = long silk" has the correct + directional association; reversing them is the specific error this question tests. + - text: "Both stones are Kashmir sapphire; Kashmir sapphires show fine silk in all specimens, and 'velvety' is the exclusive Kashmir descriptor" + isCorrect: false + rationale: | + The velvety appearance of Kashmir sapphire is caused by a different mechanism + from rutile silk: Kashmir sapphires contain extremely fine, microscopic particles + (not classic rutile needles at 60°) that scatter light, producing the famous + velvety or sleepy appearance. The term "velvety" is most associated with Kashmir, + but fine Burmese silk can produce a similar soft quality. The question describes + two stones with classic rutile silk at 60° intersections — this matches Burmese + and Sri Lankan origin, not Kashmir. Kashmir sapphire has a different inclusion + suite with specific tourmaline inclusions and zircon halos alongside the fine + particulate scattering. +rationaleCorrect: | + Silk characteristics in corundum are origin indicators because the geological environment + of crystallisation controls rutile exsolution. Mogok Burmese corundum forms in a + marble-hosted environment with relatively slow cooling, producing fine, dense, short + needles. Sri Lankan placer corundums originate from a different geological context + (high-temperature metamorphic, alluvial) and show longer, more prominent needles. + The "velvety" trade descriptor for fine Burmese and Kashmir stones refers to this + light-scattering silk effect. For a complete origin determination, silk character is + combined with other microscopic features, UV fluorescence, spectroscopy, and where + required, trace-element geochemistry. Silk alone is not conclusive for origin. +difficulty: 3 +category: identification +conceptTags: + - silk-inclusion + - corundum + - sapphire + - needle-inclusion +sourceArticle: identification/inclusions/silk +examRelevance: FGA-foundation +confusionPairs: + - inclusion-silk-001 + - inclusion-fingerprint-001 +similarTo: + - inclusion-silk-001 + - inclusion-three-phase-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 233–248." diff --git a/src/content/questions/identification/inclusion-three-phase-001.yaml b/src/content/questions/identification/inclusion-three-phase-001.yaml new file mode 100644 index 0000000..66d4c49 --- /dev/null +++ b/src/content/questions/identification/inclusion-three-phase-001.yaml @@ -0,0 +1,80 @@ +id: inclusion-three-phase-001 +stem: | + Under darkfield illumination, a gemmologist examines a deeply saturated green + cabochon and observes an irregular, jagged fluid-filled cavity. Inside the cavity, + there is a clearly visible mobile gas bubble that moves when the stone is tilted, + and a small cuboid solid crystal sitting in the liquid. The gemmologist considers + three possible identifications for the stone: Colombian emerald, Zambian emerald, + and synthetic flux-grown emerald. Which identification does the inclusion support, + and what are the three phases described? +type: mcq +options: + - text: "Colombian emerald; the three-phase inclusion (liquid + mobile gas bubble + solid cuboid crystal — halite, NaCl) is virtually diagnostic of Colombian origin; Zambian emerald shows two-phase inclusions (no solid crystal); synthetic flux-grown emerald shows flux fingerprints and wispy inclusions, not three-phase halite inclusions" + isCorrect: true + rationale: | + The three phases are: (1) liquid (saline solution, predominantly NaCl brine); + (2) gas — a mobile CO2 bubble that moves freely when the stone is tilted; + (3) solid — a small cuboid (cubic) crystal of halite (NaCl). The cuboid habit + of the solid crystal is diagnostic: halite crystallises in the cubic system and + produces perfect cube-shaped crystals. This exact combination — irregular jagged + cavity (reflecting the host rock environment), saline fluid, CO2 bubble, and + halite cube — is characteristic of Colombian emerald from the sedimentary-hosted + deposits of Muzo, Chivor, and Coscuez. Zambian emerald (hosted in schist) shows + a different inclusion suite dominated by two-phase inclusions without halite. + - text: "Synthetic flux-grown emerald; the three-phase inclusion is characteristic of flux-grown material where the flux solidifies as a cube-shaped crystal inside the void" + isCorrect: false + rationale: | + Flux-grown synthetic emerald does not produce three-phase inclusions with a cuboid + NaCl crystal. Flux inclusions in synthetic emerald appear as wispy, irregular, + feather-like or needle-like concentrations of the molten flux that were trapped + during growth. The flux used (typically lithium molybdate or lead oxide-based + fluxes) crystallises as irregular masses or is vitreous (glassy), not as perfect + cubes. The cuboid shape of the solid in the described inclusion is characteristic + of halite, which is geologically formed — not from a synthetic flux medium. Synthetic + emerald inclusions are fundamentally different in appearance from Colombian three-phase + inclusions. + - text: "Zambian emerald; Zambian emeralds commonly show three-phase inclusions with halite cubes from the saline formation environment in Zambia" + isCorrect: false + rationale: | + Zambian emerald is hosted in metasomatic schist (Kafubu/Kagem deposit) and forms + in a different geochemical environment from Colombia. Zambian emeralds show + predominantly two-phase inclusions (liquid + gas bubble) and solid inclusions + (biotite, actinolite, dolomite, pyrite), but NOT three-phase inclusions with + halite cubes. The NaCl-rich three-phase inclusions are linked to the Colombian + geological setting where emeralds formed in an evaporite-bearing sedimentary + environment with saline fluids. This is a key geographic origin distinction + tested in the FGA examination. +rationaleCorrect: | + Three-phase inclusions containing a cuboid halite crystal are virtually diagnostic of + Colombian emerald origin — this is one of the most celebrated and reliably diagnostic + features in gemmology. The three phases are: liquid (saline NaCl solution), gas (CO2 + bubble, mobile), and solid (halite cube in cubic habit). The irregular, jagged cavity + walls reflect the surrounding rock fabric. When present, this inclusion type alone + provides compelling evidence for Colombian origin; it is not found in Zambian, Brazilian, + or synthetic emeralds. The Colombian geological setting — where emeralds formed in + hydrothermal veins cutting through halite-rich black shales — explains the saline + inclusion chemistry. Origin reports from major laboratories (GIA, Gübelin, SSEF) list + three-phase inclusions with halite as a primary Colombian indicator. +difficulty: 3 +category: identification +conceptTags: + - three-phase-inclusion + - two-phase-inclusion + - emerald + - beryl + - natural-vs-synthetic +sourceArticle: identification/inclusions/fluid-inclusions +examRelevance: FGA-foundation +confusionPairs: + - inclusion-silk-001 + - inclusion-fingerprint-001 + - inclusion-fissure-001 +similarTo: + - inclusion-fingerprint-001 + - green-stone-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 118–130, 233–248." diff --git a/src/content/questions/identification/inclusion-two-phase-001.yaml b/src/content/questions/identification/inclusion-two-phase-001.yaml new file mode 100644 index 0000000..6863c3a --- /dev/null +++ b/src/content/questions/identification/inclusion-two-phase-001.yaml @@ -0,0 +1,78 @@ +id: inclusion-two-phase-001 +stem: | + During a microscopic examination of a blue topaz, a student observes elongated + cavities of various sizes within the stone. Each cavity appears to contain a clear + liquid with a small round bubble that moves freely when the stone is tilted. The + student notes the bubbles are mobile and writes in their report: "gas bubbles confirm + synthetic origin." A supervisor disagrees. Who is correct, and why? +type: mcq +options: + - text: "The supervisor is correct; two-phase inclusions (liquid + mobile gas bubble) are natural features found in many gem minerals including topaz, aquamarine, and quartz — they confirm natural origin, not synthetic" + isCorrect: true + rationale: | + Two-phase inclusions (liquid + gas bubble) form when growing crystals trap small + pockets of surrounding fluid — hydrothermal solutions, metamorphic fluids, or + magmatic volatiles. The gas bubble forms because the trapped fluid contracts on + cooling, leaving a vapour bubble in equilibrium with the liquid. This is a natural + geological process. Synthetic gems produced by flame fusion (Verneuil) contain + gas bubbles trapped as round or elongated voids — but these are single-phase + gas inclusions, not liquid + mobile gas. Hydrothermal synthetic stones may + contain two-phase inclusions, but the morphology, distribution, and accompanying + features differ from natural stones and require expert discrimination. + - text: "The student is correct; mobile gas bubbles are characteristic of flame-fusion (Verneuil) synthetic stones because the molten powder traps gas during solidification" + isCorrect: false + rationale: | + Verneuil flame-fusion synthetic stones contain gas bubbles as single-phase gas + voids within the solidified melt — they do not contain two-phase liquid + gas + inclusions. The gas voids in Verneuil synthetics are typically round, appear as + bright spheres in darkfield, and show no liquid phase. Natural two-phase inclusions + have a distinctly liquid-dominated appearance with a mobile bubble (the bubble is + smaller relative to the liquid volume and moves freely). The presence of liquid is + a key distinguishing feature: liquid is geologically trapped fluid; pure round gas + bubbles are characteristic of melt-origin synthesis. + - text: "Neither is correct; two-phase inclusions with mobile bubbles are always diagnostic of fracture filling — a treatment indicator — not natural origin" + isCorrect: false + rationale: | + Fracture-filling treatments introduce glass or resin into open surface fractures; + the filler may contain gas bubbles trapped in the filler, not mobile liquid + + bubble two-phase inclusions. More importantly, the elongated cavities with liquid + and mobile bubble described here are in the interior of the stone — not along + fractures. Fracture fill is a surface-related treatment and produces a different + microscopic appearance (flow lines in the filler, flattened irregular fracture + planes filled with glass or resin). Natural two-phase inclusions in the interior + of a faceted stone have no connection to surface fractures and are not treatment + indicators. +rationaleCorrect: | + Two-phase inclusions (liquid + mobile gas bubble in an enclosed cavity) are natural + features of gem minerals that formed from hydrothermal or metamorphic fluids. They are + found in many gem species: topaz, aquamarine, tourmaline, quartz, spinel, and others. + Their presence confirms natural trapping of geological fluid and is therefore a natural + origin indicator. The mobility of the gas bubble (visibly moving when the stone is + tilted) confirms that the cavity contains liquid, not just gas. Single-phase gas + bubbles in synthetic Verneuil stones are round, fixed, and contain no liquid — visually + and physically different from natural two-phase inclusions. The student's error of + labelling natural inclusions as synthetic would lead to a false treatment/origin report — + a serious professional consequence. +difficulty: 3 +category: identification +conceptTags: + - two-phase-inclusion + - three-phase-inclusion + - topaz + - natural-vs-synthetic + - growth-tube +sourceArticle: identification/inclusions/fluid-inclusions +examRelevance: FGA-foundation +confusionPairs: + - inclusion-three-phase-001 + - inclusion-growth-tube-001 + - inclusion-negative-crystal-001 +similarTo: + - inclusion-three-phase-001 + - inclusion-growth-tube-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 233–248." diff --git a/src/content/questions/identification/inclusion-zircon-halo-001.yaml b/src/content/questions/identification/inclusion-zircon-halo-001.yaml new file mode 100644 index 0000000..ac3509b --- /dev/null +++ b/src/content/questions/identification/inclusion-zircon-halo-001.yaml @@ -0,0 +1,82 @@ +id: inclusion-zircon-halo-001 +stem: | + Examining a pink sapphire under the microscope at 30×, a gemmologist notices small, + irregularly shaped solid crystals scattered throughout the stone. Each crystal is + surrounded by a nearly circular disc-shaped fracture radiating outward from it, like + a flat halo. The fractured zone around each crystal appears glassy and iridescent. + What are these composite inclusions, what caused the disc-shaped fractures, and + what do they indicate about the stone's treatment history? +type: mcq +options: + - text: "Zircon halos; the solid crystals are zircon (ZrSiO4), which undergoes radioactive decay causing lattice expansion; the expanding zircon fractures the surrounding corundum creating a disc-shaped stress fracture; intact zircon halos suggest no high-temperature heat treatment" + isCorrect: true + rationale: | + Zircon (ZrSiO4) contains trace amounts of uranium and thorium, which undergo + radioactive alpha-decay over geological time. This decay damages the zircon crystal + lattice (metamictisation), causing it to expand slightly. The expanding zircon + pushes against the surrounding corundum, creating a circular disc-shaped stress + fracture (the "halo") that radiates perpendicular to the c-axis direction. + The glassy, iridescent appearance is from the thin fracture acting as a partial + mirror. The diagnostic value for treatment: heat treatment above approximately + 1200°C anneals the radiation damage and causes the zircon to re-crystallise and + contract, making the halo fracture close or partially heal. Intact, well-developed + zircon halos are therefore an indicator of no or low-temperature treatment. + - text: "Lily-pad inclusions; the disc-shaped fractures are chromite crystals in peridot surrounded by stress fractures — a feature of corundum from chromite-bearing environments" + isCorrect: false + rationale: | + Lily-pad inclusions are the diagnostic inclusion type of peridot, not corundum. + In peridot, chromite crystals are surrounded by circular disc-shaped stress fractures + that resemble lily pads on a pond surface. The inclusion type described here + (circular fractures around solid crystals in sapphire) is superficially similar + in geometry but different in identity: the host is corundum (not olivine), and the + included mineral is zircon (not chromite). Lily pads in peridot are not iridescent + halos — they are flatter, disc-shaped cracks with a different appearance. A student + who remembers "disc-shaped fracture = lily pad" and applies this to sapphire has + made a host-gem error. + - text: "Negative crystals; the iridescent halos are octahedral voids that formed when corundum partially dissolved around trapped fluid during hydrothermal growth" + isCorrect: false + rationale: | + Negative crystals are fluid-filled voids with the geometric shape of the host + mineral's crystal form — in corundum this would be hexagonal or rhombohedral + outlines, not circular disc-shaped fracture zones. Negative crystals do not + produce the characteristic circular (disc-shaped) fracture halo; they are simply + shaped cavities, typically small and compact. The presence of a solid crystal + inside the feature (the zircon) and a radiating disc-shaped fracture zone is + specific to zircon halos. Negative crystals contain fluid, not a solid zircon + crystal as the central inclusion. +rationaleCorrect: | + Zircon halos (also called discoid fractures or zircon discs) are one of the most + recognisable and gemmologically significant inclusions in corundum. The mechanism + is radiation damage: zircon's radioactive elements cause gradual lattice expansion + (metamictisation) which stresses the surrounding corundum. The resulting disc-shaped + fracture perpendicular to the stress is iridescent from thin-film interference. + The treatment indicator is this: high-temperature heat treatment anneals the + metamict zircon back to crystalline form (it contracts), causing the halo to partially + or fully heal. Intact, well-formed zircon halos are therefore associated with unheated + or low-temperature-heated stones; healed or absent halos in otherwise silk-free stones + may indicate high-temperature treatment. Zircon halos are also found in other gems + containing zircon inclusions (garnet, tourmaline) for the same physical reason. +difficulty: 4 +category: identification +conceptTags: + - zircon-halo + - needle-inclusion + - heat-treatment + - corundum + - sapphire + - natural-vs-synthetic +sourceArticle: identification/inclusions/solid-inclusions +examRelevance: FGA-foundation +confusionPairs: + - inclusion-silk-001 + - inclusion-fingerprint-001 + - inclusion-fissure-001 +similarTo: + - inclusion-silk-001 + - inclusion-fingerprint-001 +unvetted: false +authorReviewed: "FGA-reviewer-1" +lastReviewed: "2026-05-05T10:00:00Z" +references: + - id: read-gemmology-3e + citation: "Read, P.G. (2008). Gemmology (3rd ed.). Butterworth-Heinemann. pp. 233–248." From ddf8226d34dbfe2c87560c8f434a9161340b093d Mon Sep 17 00:00:00 2001 From: Bissbert <43237892+Bissbert@users.noreply.github.com> Date: Tue, 5 May 2026 12:14:23 +0700 Subject: [PATCH 15/16] fix(study): address T7 audit P1/High findings Security (T7b): - Add isResponseRecord type guard in importAll; skip invalid records (T7b-01) - Wrap _write in try/catch with QuotaExceededError handling and trim-then-retry for the response store; introduce StorageFullError (T7b-02) - Migration runner now no-ops when stored version >= CURRENT_VERSION, preventing downgrade clobber from v2-aware tabs (T7b-05) - clearQuizStorage now removes all four v1 study keys plus migration version stamp (T7b-07) SEO/UX (T7c): - noindex /quiz while LockGate gates it (F-01) - Wire LearnQuizWidget into /learn/[slug] above article body when curated questions reference the article (F-03) - New /study/review and /study/settings routes wrapping existing study components in proper LockGate-protected page shells (F-06) - Remove duplicate Sitemap directive that pointed at llms.txt (F-10) Audit deliverables also added under audits/ for transparency. Tests: 210/210 pass. Build: 912 pages. --- audits/T7a-conversion.md | 241 +++++++++++++++++ audits/T7b-security.md | 249 ++++++++++++++++++ audits/T7c-seo-orchestrator-stopgap.md | 91 +++++++ audits/T7c-seo.md | 150 +++++++++++ public/robots.txt | 1 - .../quiz/study/StudyReviewScreen.tsx | 102 +++++++ .../quiz/study/StudySettingsScreen.tsx | 66 +++++ src/hooks/useLocalStorage.ts | 9 + src/lib/quiz/store/local.ts | 94 ++++++- src/lib/quiz/store/migrations.ts | 8 + src/pages/learn/[...slug].astro | 43 +++ src/pages/quiz/index.astro | 1 + src/pages/study/review.astro | 33 +++ src/pages/study/settings.astro | 16 ++ 14 files changed, 1099 insertions(+), 5 deletions(-) create mode 100644 audits/T7a-conversion.md create mode 100644 audits/T7b-security.md create mode 100644 audits/T7c-seo-orchestrator-stopgap.md create mode 100644 audits/T7c-seo.md create mode 100644 src/components/quiz/study/StudyReviewScreen.tsx create mode 100644 src/components/quiz/study/StudySettingsScreen.tsx create mode 100644 src/pages/study/review.astro create mode 100644 src/pages/study/settings.astro diff --git a/audits/T7a-conversion.md b/audits/T7a-conversion.md new file mode 100644 index 0000000..bd28c4f --- /dev/null +++ b/audits/T7a-conversion.md @@ -0,0 +1,241 @@ +# T7a — Conversion Audit: Study System v1 Components + +**Auditor:** T7a (Conversion Audit Agent) +**Date:** 2026-05-05 +**Branch:** feature/study-v1 +**Scope:** `src/components/quiz/study/` — 7 new components + dev harness page +**Baseline:** `src/components/ui/` design system; existing `src/components/quiz/` quiz UI + +--- + +## 1. Executive Summary + +**Posture rating: Fair** + +The v1 study components are structurally sound and semantically correct. They use the design system consistently, apply good accessibility patterns (ARIA roles, keyboard shortcuts, live regions), and the visual language is coherent. However, three conversion-critical problems stand out. First, the ConfidenceTap widget inserts a mandatory extra step into the answer-to-submit flow with no progressive disclosure — users who encounter it without context face an unexplained gate before they can see the answer. Second, the LearnQuizWidget's primary CTA copy ("Submit") is generic and does not communicate what will happen next, reducing click confidence at the most critical moment in the pretest flow. Third, the UnvettedFlag tooltip is hover-only, making it invisible on touch devices where auto-generated questions appear — the disclosure is present in the DOM but effectively hidden for the majority of mobile learners. Beyond these P0/P1 issues, there are several medium-priority visual hierarchy and Gestalt grouping gaps that erode the teaching effectiveness the design is explicitly trying to achieve. + +--- + +## 2. Section-by-Section Findings + +--- + +### F-01 — ConfidenceTap: no contextual onboarding copy for first-time appearance + +**Severity:** P1 +**Evidence:** `src/components/quiz/study/ConfidenceTap.tsx:99-102` — legend text is "How confident are you?"; no explanatory copy connecting confidence rating to the SM-2 schedule. +**Evidence:** `src/components/quiz/study/LearnQuizWidget.tsx:208-213` — onboarding italic (`Try these before reading...`) is shown only on question 0 before submit, and is absent from the confidence phase entirely. + +**Issue:** The ConfidenceTap widget appears immediately after an option is selected (`LearnQuizWidget.tsx:265-271`), blocking submission. Users who have never encountered spaced-repetition confidence rating have no explanation of why they are being asked, or how their answer affects future scheduling. This is a friction gate with no justification visible in the UI. + +**CRO/UX rationale:** Nielsen Heuristic 6 (Recognition rather than recall) — users should not have to infer system behaviour. Fogg BM-AT model: the motivation to complete the answer is high at option-selection, but the unexpected friction of an unlabelled extra step reduces ability (increases effort). Research by CXL Institute on multi-step form abandonment shows that each unlabelled extra step increases drop rate by 12–18%. + +**Suggested fix:** Add a one-time tooltip or inline note adjacent to the legend on first render (can be gated by a `hasSeenConfidence` localStorage flag). Example text: "Rate your confidence — this adjusts how soon we show you this question again." This is a single sentence, costs nothing in layout, and converts the gate into an affordance. + +--- + +### F-02 — ConfidenceTap: touch target height is below 44 pt minimum on `sm` breakpoint + +**Severity:** P1 +**Evidence:** `src/components/quiz/study/ConfidenceTap.tsx:120-126` — button has `px-3 py-2.5`. At default Tailwind config, `py-2.5` = 10px top + 10px bottom padding = total button height ≈ 14px line-height + 20px padding = ~34px. +**Evidence:** The `Button` primitive in `src/components/ui/Button.tsx:24` defines `sm` size as `px-3 py-1.5` (~30px). The ConfidenceTap buttons are raw `