= {
+ none: 0,
+ weak: 1,
+ moderate: 2,
+ strong: 3,
+ very_strong: 4,
+};
+
+/** Normalise a colour string for fuzzy comparison. */
+function normaliseColour(s: string): string {
+ return s
+ .toLowerCase()
+ .trim()
+ .replace(/[\s\-_/]+/g, ' ')
+ .replace(/\bish\b/g, '')
+ .replace(/\s+/g, ' ');
+}
+
+/** Tokenise a colour name into bare colour words: "yellowish-green" → ["yellow","green"]. */
+function colourTokens(s: string): string[] {
+ const root = normaliseColour(s)
+ .replace(/(?:bluish|greenish|yellowish|reddish|pinkish|orangish|brownish|purplish|violetish)/g, (m) =>
+ m.replace(/ish$/, ''),
+ );
+ return root.split(' ').filter(Boolean);
+}
+
+/**
+ * Compare one observed colour to one stored colour.
+ * Returns 1.0 for exact / strong overlap, 0.5 for partial token overlap, 0 otherwise.
+ */
+function colourSimilarity(observed: string, stored: string | undefined | null): number {
+ if (!stored) return 0;
+ const a = normaliseColour(observed);
+ const b = normaliseColour(stored);
+ if (!a || !b) return 0;
+ if (a === b) return 1;
+ if (a.includes(b) || b.includes(a)) return 0.85;
+
+ const ta = new Set(colourTokens(a));
+ const tb = new Set(colourTokens(b));
+ let shared = 0;
+ ta.forEach((t) => {
+ if (tb.has(t)) shared++;
+ });
+ if (shared === 0) return 0;
+ const denom = Math.max(ta.size, tb.size);
+ return Math.min(0.6, shared / denom);
+}
+
+/**
+ * Score the match between observed colours and the gem's stored 2-3 colours.
+ * Uses greedy assignment: each observed colour pairs with its best remaining stored colour.
+ */
+function colourSetSimilarity(observed: string[], stored: (string | undefined | null)[]): number {
+ const cleanStored = stored.filter((s): s is string => Boolean(s && s.trim()));
+ if (observed.length === 0 || cleanStored.length === 0) return 0;
+
+ const remaining = [...cleanStored];
+ let total = 0;
+ for (const obs of observed) {
+ let bestIdx = -1;
+ let bestSim = 0;
+ remaining.forEach((s, i) => {
+ const sim = colourSimilarity(obs, s);
+ if (sim > bestSim) {
+ bestSim = sim;
+ bestIdx = i;
+ }
+ });
+ if (bestIdx >= 0) {
+ total += bestSim;
+ remaining.splice(bestIdx, 1);
+ }
+ }
+ return total / observed.length;
+}
+
+function storedColourCountOf(m: Mineral): 1 | 2 | 3 | 0 {
+ if (m.pleochroism_color3 && m.pleochroism_color3.trim()) return 3;
+ if (m.pleochroism_color2 && m.pleochroism_color2.trim()) return 2;
+ if (m.pleochroism_color1 && m.pleochroism_color1.trim()) return 1;
+ return 0;
+}
+
+/**
+ * Match observed pleochroism against the mineral database.
+ *
+ * @param criteria what the user observed
+ * @param minerals mineral list (already loaded by the caller, e.g. via getAllMinerals)
+ * @returns matches ranked by score, descending, capped at 50
+ */
+export function matchPleochroism(
+ criteria: PleochroismCriteria,
+ minerals: Mineral[],
+): PleochroismMatch[] {
+ const observedColours = criteria.colours
+ .map((c) => c.trim())
+ .filter(Boolean);
+
+ const matches: PleochroismMatch[] = [];
+
+ for (const mineral of minerals) {
+ const storedCount = storedColourCountOf(mineral);
+ if (storedCount === 0) continue;
+
+ // Step 1 — colour-count match.
+ // 1 observed: ANY pleochroic gem could read as 1 if viewed along optic axis;
+ // so we don't penalise but we DO prefer exact-count matches.
+ // 2 observed: dichroic or trichroic-viewed-from-side; dichroic gems score higher.
+ // 3 observed: only trichroic gems (storedCount === 3).
+ let countScore: number;
+ if (criteria.colourCount === storedCount) {
+ countScore = 1.0;
+ } else if (criteria.colourCount === 3 && storedCount < 3) {
+ // Three observed colours but DB knows only 2 → impossible match.
+ continue;
+ } else if (criteria.colourCount === 2 && storedCount === 3) {
+ // Possible if viewed off the optic-plane bisector — partial credit.
+ countScore = 0.6;
+ } else if (criteria.colourCount === 1) {
+ // Saw one colour — could be any gem viewed favourably; mild penalty for richer ones.
+ countScore = storedCount === 1 ? 1.0 : 0.4;
+ } else {
+ countScore = 0.3;
+ }
+
+ // Step 2 — colour similarity.
+ const stored = [
+ mineral.pleochroism_color1,
+ mineral.pleochroism_color2,
+ mineral.pleochroism_color3,
+ ];
+ const colourScore =
+ observedColours.length === 0 ? 1 : colourSetSimilarity(observedColours, stored);
+
+ // Step 3 — strength similarity (optional).
+ let strengthScore = 1;
+ if (criteria.strength !== 'unknown' && mineral.pleochroism_strength) {
+ const obsRank = STRENGTH_RANK[criteria.strength] ?? 2;
+ const dbRank = STRENGTH_RANK[mineral.pleochroism_strength] ?? 2;
+ const diff = Math.abs(obsRank - dbRank);
+ strengthScore = Math.max(0, 1 - diff * 0.25);
+ }
+
+ // Weighted overall score: colour overlap matters most, count next, strength last.
+ const score = colourScore * 0.6 + countScore * 0.3 + strengthScore * 0.1;
+ if (score < 0.2) continue;
+
+ const reasonParts: string[] = [];
+ reasonParts.push(
+ storedCount === 3
+ ? 'Trichroic — biaxial (orthorhombic, monoclinic, or triclinic).'
+ : storedCount === 2
+ ? 'Dichroic — uniaxial (trigonal, tetragonal, or hexagonal).'
+ : 'Single observed colour — pleochroism not detectable.',
+ );
+ if (mineral.pleochroism_strength) {
+ reasonParts.push(
+ `Strength: ${mineral.pleochroism_strength.replace('_', ' ')}.`,
+ );
+ }
+ if (mineral.pleochroism_notes) {
+ reasonParts.push(mineral.pleochroism_notes);
+ }
+
+ matches.push({
+ mineral,
+ score,
+ reason: reasonParts.join(' '),
+ storedColourCount: storedCount as 1 | 2 | 3,
+ });
+ }
+
+ matches.sort((a, b) => b.score - a.score);
+ return matches.slice(0, 50);
+}
+
+/**
+ * Educational interpretation of the colour count itself, independent of any match.
+ * Used in the UI as a "what your observation implies" callout.
+ */
+export function interpretColourCount(count: ObservedColourCount): {
+ title: string;
+ body: string;
+} {
+ switch (count) {
+ case 1:
+ return {
+ title: 'One colour observed',
+ body:
+ 'The gem may be isotropic (cubic or amorphous — diamond, garnet, spinel, glass, opal), or anisotropic but viewed along its optic axis. Rotate the dichroscope and the stone; if no second colour appears at any orientation, isotropic is most likely.',
+ };
+ case 2:
+ return {
+ title: 'Two colours observed (dichroic)',
+ body:
+ 'The gem is uniaxial: trigonal, tetragonal, or hexagonal. Examples include corundum (ruby/sapphire), tourmaline, beryl (emerald/aquamarine), zircon, and quartz.',
+ };
+ case 3:
+ return {
+ title: 'Three colours observed (trichroic)',
+ body:
+ 'The gem is biaxial: orthorhombic, monoclinic, or triclinic. Examples include andalusite, iolite, tanzanite, kunzite, topaz, and peridot.',
+ };
+ }
+}
From b3500441702eb156d13e6b1ec59cd8e4394f9894 Mon Sep 17 00:00:00 2001
From: Bissbert <43237892+Bissbert@users.noreply.github.com>
Date: Tue, 5 May 2026 20:43:59 +0700
Subject: [PATCH 3/3] =?UTF-8?q?feat(tools):=20wave=20C=20=E2=80=94=20UV,?=
=?UTF-8?q?=20spectroscope,=20treatment=20wizard,=20optic-sign=20reasoner?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds four new gemmological reasoning widgets backed by pure-TypeScript
parsers and reference tables, avoiding a multi-day Python schema
migration on the mineral-database package. The same data can later be
populated into SQL columns without breaking these widgets.
C1 — UV fluorescence lookup (`/tools/lab`): parses the freeform
`fluorescence` field for LWUV / SWUV / phosphorescence and ranks
candidates by colour and intensity. Surfaces a treatment red flag for
chalky SWUV in corundum (heat-treatment indicator).
C2 — Spectroscope band-matcher (`/tools/lab`): inverse to the
display-only SpectroscopeCalculator. User ticks observed absorption
lines on a colour-coded wavelength scale; widget ranks 16 species from
a curated reference table (chrome doublet, didymium, almandine 504/527,
zircon U-line, peridot triplet, cape diamond N3, etc.). Selective
(diagnostic) bands dominate the ranking.
C3 — Treatment-detection wizard (`/tools/advanced`): pick the gem kind,
tick observed clues (silk halos, chalky SWUV, flash effect, surface
bubbles, residue, jadeite acid-etch, HPHT decolourisation, …); evidence
weighting produces a ranked likelihood per treatment with positive and
negative supporting clues.
C4 — Optic-sign / 2V reasoner (`/tools/optical`): user enters polariscope
result + ω/ε (uniaxial) or α/β/γ (biaxial); widget computes optic sign,
birefringence, and 2V via Mallard's formula, then ranks species whose
parsed `optical_character` matches the observation.
All four ship with vitest coverage of their pure matchers (parsers,
sign rules, scoring functions). 288/288 tests pass; build green.
---
src/components/advanced/AdvancedTools.tsx | 12 +
src/components/advanced/TreatmentWizard.tsx | 184 +++++++++++
src/components/advanced/index.ts | 1 +
src/components/lab/LabTools.tsx | 23 ++
.../lab/SpectroscopeBandMatcher.tsx | 190 +++++++++++
src/components/lab/UvFluorescenceLookup.tsx | 239 ++++++++++++++
src/components/optical/OpticSignReasoner.tsx | 311 ++++++++++++++++++
src/components/optical/OpticalTools.tsx | 12 +
src/components/optical/index.ts | 2 +
src/lib/optic-sign/optic-character.test.ts | 101 ++++++
src/lib/optic-sign/optic-character.ts | 138 ++++++++
src/lib/spectroscope/match-bands.test.ts | 34 ++
src/lib/spectroscope/match-bands.ts | 104 ++++++
src/lib/spectroscope/reference-bands.ts | 217 ++++++++++++
src/lib/treatments/wizard.test.ts | 93 ++++++
src/lib/treatments/wizard.ts | 239 ++++++++++++++
.../parse-fluorescence.test.ts | 62 ++++
src/lib/uv-fluorescence/parse-fluorescence.ts | 140 ++++++++
18 files changed, 2102 insertions(+)
create mode 100644 src/components/advanced/TreatmentWizard.tsx
create mode 100644 src/components/lab/SpectroscopeBandMatcher.tsx
create mode 100644 src/components/lab/UvFluorescenceLookup.tsx
create mode 100644 src/components/optical/OpticSignReasoner.tsx
create mode 100644 src/lib/optic-sign/optic-character.test.ts
create mode 100644 src/lib/optic-sign/optic-character.ts
create mode 100644 src/lib/spectroscope/match-bands.test.ts
create mode 100644 src/lib/spectroscope/match-bands.ts
create mode 100644 src/lib/spectroscope/reference-bands.ts
create mode 100644 src/lib/treatments/wizard.test.ts
create mode 100644 src/lib/treatments/wizard.ts
create mode 100644 src/lib/uv-fluorescence/parse-fluorescence.test.ts
create mode 100644 src/lib/uv-fluorescence/parse-fluorescence.ts
diff --git a/src/components/advanced/AdvancedTools.tsx b/src/components/advanced/AdvancedTools.tsx
index f0daed0..6c8ce0f 100644
--- a/src/components/advanced/AdvancedTools.tsx
+++ b/src/components/advanced/AdvancedTools.tsx
@@ -4,11 +4,13 @@
*/
import { TreatmentDetection } from './TreatmentDetection';
+import { TreatmentWizard } from './TreatmentWizard';
import { ProportionAnalyzer } from './ProportionAnalyzer';
import { ToolSection } from '../ui/ToolSection';
const ICON_PATHS = {
treatment: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
+ wizard: 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z',
ruler: 'M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01',
};
@@ -25,6 +27,16 @@ export function AdvancedTools() {
+
+
+
+
= {
+ unlikely: 'bg-slate-100 text-slate-700',
+ possible: 'bg-amber-50 text-amber-800 border border-amber-200',
+ likely: 'bg-orange-100 text-orange-800',
+ 'very likely': 'bg-rose-100 text-rose-800 border border-rose-200',
+};
+
+export function TreatmentWizard() {
+ const [gemKind, setGemKind] = useState('corundum');
+ const [selected, setSelected] = useState>(new Set());
+
+ const availableClues = useMemo(() => cluesForKind(gemKind), [gemKind]);
+
+ const verdicts = useMemo(
+ () => runWizard({ gemKind, selectedClueIds: Array.from(selected) }),
+ [gemKind, selected],
+ );
+
+ const toggle = (id: string) => {
+ setSelected((s) => {
+ const next = new Set(s);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ const handleGemChange = (next: string) => {
+ setGemKind(next as GemKind);
+ // Drop clues that no longer apply to the new kind.
+ const allowedIds = new Set(cluesForKind(next as GemKind).map((c) => c.id));
+ setSelected((s) => new Set(Array.from(s).filter((id) => allowedIds.has(id))));
+ };
+
+ return (
+
+
+ Pick the gem kind, tick the clues you actually see under loupe, microscope, UV, or warming.
+ The wizard weighs each clue and ranks treatments by likelihood. A negative score means the
+ clue argues against that treatment.
+
+
+
+
+
+
+
+
+ Observed clues ({selected.size} ticked of {availableClues.length})
+
+
+ {availableClues.map((clue) => {
+ const isOn = selected.has(clue.id);
+ return (
+
+ toggle(clue.id)}
+ className="mt-1 accent-cyan-600"
+ />
+
+
{clue.label}
+ {clue.description && (
+
{clue.description}
+ )}
+
+
+ );
+ })}
+
+ {selected.size > 0 && (
+
setSelected(new Set())}
+ className="mt-3 text-xs text-slate-600 hover:text-slate-900 underline"
+ >
+ Clear all clues
+
+ )}
+
+
+ {selected.size === 0 ? (
+
+ Tick at least one clue above to see ranked treatments.
+
+ ) : verdicts.length === 0 ? (
+
+ Selected clues do not point to any common treatment — likely natural / untreated within
+ the limits of these observations.
+
+ ) : (
+
+
+ {verdicts.length} candidate treatment{verdicts.length === 1 ? '' : 's'}
+
+ {verdicts.map((v) => (
+
+
+
{v.label}
+
+ {v.confidence} (score {v.score >= 0 ? '+' : ''}
+ {v.score})
+
+
+ {v.supportingClueIds.length > 0 && (
+
+ Supports: {' '}
+ {v.supportingClueIds
+ .map((id) => availableClues.find((c) => c.id === id)?.label ?? id)
+ .join('; ')}
+
+ )}
+ {v.contradictingClueIds.length > 0 && (
+
+ Argues against: {' '}
+ {v.contradictingClueIds
+ .map((id) => availableClues.find((c) => c.id === id)?.label ?? id)
+ .join('; ')}
+
+ )}
+
+ ))}
+
+ )}
+
+
+ Note: this wizard reasons over visual & instrumental clues only. Some treatments (e.g.
+ beryllium lattice diffusion, low-temperature heating of pastel sapphire) require advanced
+ spectroscopy (LIBS / FTIR / UV-Vis) for definitive detection. Consult a recognised gem
+ laboratory when stakes are high.
+
+
+ );
+}
diff --git a/src/components/advanced/index.ts b/src/components/advanced/index.ts
index 0f93dbd..5a03076 100644
--- a/src/components/advanced/index.ts
+++ b/src/components/advanced/index.ts
@@ -3,5 +3,6 @@
*/
export { TreatmentDetection } from './TreatmentDetection';
+export { TreatmentWizard } from './TreatmentWizard';
export { ProportionAnalyzer } from './ProportionAnalyzer';
export { AdvancedTools } from './AdvancedTools';
diff --git a/src/components/lab/LabTools.tsx b/src/components/lab/LabTools.tsx
index 35ff554..4f9c202 100644
--- a/src/components/lab/LabTools.tsx
+++ b/src/components/lab/LabTools.tsx
@@ -5,13 +5,16 @@
import { ChelseaFilter } from './ChelseaFilter';
import { SpectroscopeCalculator } from './SpectroscopeCalculator';
+import { SpectroscopeBandMatcher } from './SpectroscopeBandMatcher';
import { HeavyLiquidReference } from './HeavyLiquidReference';
+import { UvFluorescenceLookup } from './UvFluorescenceLookup';
import { ToolSection } from '../ui/ToolSection';
const ICON_PATHS = {
filter: 'M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V4z',
spectrum: 'M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01',
beaker: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
+ uv: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
};
export function LabTools() {
@@ -37,6 +40,26 @@ export function LabTools() {
+
+
+
+
+
+
+
+
= {
+ violet: 'bg-violet-200 text-violet-900',
+ blue: 'bg-blue-200 text-blue-900',
+ cyan: 'bg-cyan-200 text-cyan-900',
+ green: 'bg-green-200 text-green-900',
+ yellow: 'bg-yellow-200 text-yellow-900',
+ orange: 'bg-orange-200 text-orange-900',
+ red: 'bg-red-200 text-red-900',
+};
+
+export function SpectroscopeBandMatcher() {
+ const [selected, setSelected] = useState>(new Set());
+ const [customNm, setCustomNm] = useState('');
+ const [tolerance, setTolerance] = useState('5');
+
+ const observed = useMemo(() => Array.from(selected).sort((a, b) => a - b), [selected]);
+ const matches = useMemo(
+ () => matchBands(observed, parseFloat(tolerance) || 5),
+ [observed, tolerance],
+ );
+
+ const toggle = (wl: number) => {
+ setSelected((s) => {
+ const next = new Set(s);
+ if (next.has(wl)) next.delete(wl);
+ else next.add(wl);
+ return next;
+ });
+ };
+
+ const addCustom = () => {
+ const v = parseFloat(customNm);
+ if (isNaN(v) || v < 350 || v > 800) return;
+ setSelected((s) => new Set(s).add(Math.round(v)));
+ setCustomNm('');
+ };
+
+ return (
+
+
+ Tick every absorption line you can see in the spectroscope. The reasoner ranks species
+ whose stored band patterns match. Selective (diagnostic) bands are weighted more heavily.
+
+
+
+
Observed bands
+
+ {REFERENCE_BANDS.map((wl) => {
+ const isOn = selected.has(wl);
+ const swatch = COLOUR_SWATCH[colourFor(wl)];
+ return (
+ toggle(wl)}
+ className={`text-xs px-2.5 py-1 rounded font-mono border transition ${
+ isOn ? `${swatch} border-slate-700 ring-2 ring-slate-400` : `${swatch} border-transparent opacity-60 hover:opacity-100`
+ }`}
+ title={`${wl} nm — ${colourFor(wl)} region`}
+ >
+ {wl} nm
+
+ );
+ })}
+
+
+
+
+
+
+
+ Add
+
+
+
+
+
+
+
+
+
+ setSelected(new Set())}
+ disabled={selected.size === 0}
+ className="px-3 py-2 bg-slate-200 text-slate-700 text-sm rounded hover:bg-slate-300 disabled:opacity-50"
+ >
+ Clear all bands
+
+
+
+
+
+ {observed.length === 0 ? (
+
+ Tick at least one band above to see ranked candidates.
+
+ ) : matches.length === 0 ? (
+
+ No reference species match these bands within ± {tolerance} nm. Try widening the tolerance or rechecking the readings against a reference scale.
+
+ ) : (
+
+
+ {matches.length} candidate {matches.length === 1 ? 'species' : 'species'} ({observed.length} band{observed.length === 1 ? '' : 's'} ticked)
+
+ {matches.map((m) => (
+
+
+
+
coverage {(m.coverage * 100).toFixed(0)}%
+
+
{m.reason}
+
+ {m.matched.map((mb, i) => (
+
+ {mb.observed} nm{mb.band.cause ? ` (${mb.band.cause})` : ''}
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/lab/UvFluorescenceLookup.tsx b/src/components/lab/UvFluorescenceLookup.tsx
new file mode 100644
index 0000000..07301b7
--- /dev/null
+++ b/src/components/lab/UvFluorescenceLookup.tsx
@@ -0,0 +1,239 @@
+/**
+ * UV Fluorescence Lookup widget.
+ *
+ * The user reports observed LWUV and SWUV reactions; the widget parses the
+ * freeform `fluorescence` field of every mineral family in the database and
+ * returns a ranked candidate list, with treatment red-flag warnings where
+ * the observation matches a known treatment signature (e.g., chalky SWUV
+ * in heated sapphire).
+ */
+
+import { useEffect, useMemo, useState } from 'react';
+import { getAllFamilies, type MineralFamily } from '../../lib/db';
+import {
+ parseFluorescence,
+ scoreUvMatch,
+ type UvIntensity,
+} from '../../lib/uv-fluorescence/parse-fluorescence';
+import { FormField, Select } from '../form';
+import { Pagination } from '../ui';
+import { usePagination } from '../../hooks/usePagination';
+
+const INTENSITY_OPTIONS: { value: UvIntensity; label: string }[] = [
+ { value: 'unknown', label: '— skip this band —' },
+ { value: 'inert', label: 'Inert (no reaction)' },
+ { value: 'weak', label: 'Weak' },
+ { value: 'moderate', label: 'Moderate' },
+ { value: 'strong', label: 'Strong' },
+ { value: 'very_strong', label: 'Very strong' },
+];
+
+const COLOR_OPTIONS = [
+ { value: '', label: '— any colour —' },
+ { value: 'red', label: 'Red' },
+ { value: 'orange', label: 'Orange' },
+ { value: 'yellow', label: 'Yellow' },
+ { value: 'green', label: 'Green' },
+ { value: 'blue', label: 'Blue' },
+ { value: 'violet', label: 'Violet / purple' },
+ { value: 'pink', label: 'Pink' },
+ { value: 'white', label: 'White' },
+ { value: 'chalky', label: 'Chalky / cloudy' },
+];
+
+interface MatchRow {
+ family: MineralFamily;
+ score: number;
+ treatmentFlag?: string;
+}
+
+function detectTreatmentFlag(family: MineralFamily, swuvColor: string): string | undefined {
+ if (!family.fluorescence) return undefined;
+ const text = family.fluorescence.toLowerCase();
+ // Classic chalky-SWUV reaction in heat-treated sapphire.
+ if (
+ swuvColor === 'chalky' &&
+ /sapphire|corundum/.test(family.name.toLowerCase()) &&
+ /chalk|cloudy/.test(text)
+ ) {
+ return 'Chalky SWUV is a strong indicator of heat treatment in corundum.';
+ }
+ return undefined;
+}
+
+export function UvFluorescenceLookup() {
+ const [families, setFamilies] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [dbError, setDbError] = useState(null);
+
+ const [lwuvIntensity, setLwuvIntensity] = useState('strong');
+ const [lwuvColor, setLwuvColor] = useState('');
+ const [swuvIntensity, setSwuvIntensity] = useState('unknown');
+ const [swuvColor, setSwuvColor] = useState('');
+
+ useEffect(() => {
+ let mounted = true;
+ (async () => {
+ try {
+ const all = await getAllFamilies();
+ if (!mounted) return;
+ setFamilies(all);
+ } catch (err) {
+ if (!mounted) return;
+ setDbError(err instanceof Error ? err.message : 'Database load failed');
+ } finally {
+ if (mounted) setLoading(false);
+ }
+ })();
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ const obs = { lwuvIntensity, lwuvColor, swuvIntensity, swuvColor };
+ const hasObservation = lwuvIntensity !== 'unknown' || swuvIntensity !== 'unknown';
+
+ const matches = useMemo(() => {
+ if (!hasObservation || families.length === 0) return [];
+ const out: MatchRow[] = [];
+ for (const family of families) {
+ const fl = parseFluorescence(family.fluorescence);
+ if (!fl) continue;
+ const score = scoreUvMatch(obs, fl);
+ if (score < 0.4) continue;
+ out.push({
+ family,
+ score,
+ treatmentFlag: detectTreatmentFlag(family, swuvColor),
+ });
+ }
+ return out.sort((a, b) => b.score - a.score).slice(0, 50);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [families, lwuvIntensity, lwuvColor, swuvIntensity, swuvColor]);
+
+ const { page, params, onPageChange, onPageSizeChange, resetPage } = usePagination({
+ initialPageSize: 10,
+ });
+ useEffect(() => {
+ resetPage();
+ }, [matches.length, resetPage]);
+
+ const totalPages = Math.ceil(matches.length / params.pageSize);
+ const startIndex = (page - 1) * params.pageSize;
+ const paginated = matches.slice(startIndex, startIndex + params.pageSize);
+ const pagination = {
+ page,
+ pageSize: params.pageSize,
+ total: matches.length,
+ totalPages,
+ hasNext: page < totalPages,
+ hasPrev: page > 1,
+ };
+
+ return (
+
+
+ Observe the stone under both long-wave (365 nm) and short-wave (254 nm) UV in a darkened
+ cabinet and report what you see. The reasoner ranks species whose stored fluorescence text
+ matches your observation.
+
+
+
+
Long-wave (365 nm)
+
+
+ setLwuvIntensity(v as UvIntensity)}
+ />
+
+
+
+
+
+
+
+
+
Short-wave (254 nm)
+
+
+ setSwuvIntensity(v as UvIntensity)}
+ />
+
+
+
+
+
+
+
+ {loading && (
+
+ Loading mineral database…
+
+ )}
+ {dbError && (
+
+ Database unavailable: {dbError}
+
+ )}
+
+ {!loading && !dbError && families.length > 0 && (
+ <>
+ {!hasObservation ? (
+
+ Select an LW or SW intensity to see ranked candidates.
+
+ ) : matches.length === 0 ? (
+
+ No species match these UV reactions. Try widening colour or intensity, or recheck observations under darker conditions.
+
+ ) : (
+
+
+ {matches.length} candidate {matches.length === 1 ? 'family' : 'families'}
+
+ {paginated.map((m) => (
+
+
+ {m.family.fluorescence && (
+
+ Stored: {m.family.fluorescence}
+
+ )}
+ {m.treatmentFlag && (
+
+ ⚠ {m.treatmentFlag}
+
+ )}
+
+ ))}
+ {totalPages > 1 && (
+
+ )}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/components/optical/OpticSignReasoner.tsx b/src/components/optical/OpticSignReasoner.tsx
new file mode 100644
index 0000000..8cf08b3
--- /dev/null
+++ b/src/components/optical/OpticSignReasoner.tsx
@@ -0,0 +1,311 @@
+/**
+ * Optic-sign / 2V reasoner.
+ *
+ * The user reports the optic character seen in the polariscope plus the
+ * relevant refractive indices (ω/ε for uniaxial, α/β/γ for biaxial). The
+ * widget computes the optic sign, birefringence, and 2V (when applicable),
+ * then ranks candidate species from the database whose stored
+ * `optical_character` matches the observation.
+ *
+ * The mineral DB stores optic character as freeform text — we parse it via
+ * `parseOpticalCharacter()` so no SQL schema migration is needed.
+ */
+
+import { useEffect, useMemo, useState } from 'react';
+import { getAllMinerals, type Mineral } from '../../lib/db';
+import {
+ parseOpticalCharacter,
+ uniaxialSign,
+ biaxialSign,
+ biaxial2V,
+ characterMatches,
+ type OpticCharacterKind,
+ type OpticSign,
+} from '../../lib/optic-sign/optic-character';
+import { FormField, NumberInput, Select } from '../form';
+import { Pagination } from '../ui';
+import { usePagination } from '../../hooks/usePagination';
+
+const CHARACTER_OPTIONS: { value: OpticCharacterKind; label: string }[] = [
+ { value: 'isotropic', label: 'Isotropic (single dark cross or dark all rotations)' },
+ { value: 'uniaxial', label: 'Uniaxial (one optic axis)' },
+ { value: 'biaxial', label: 'Biaxial (two optic axes)' },
+ { value: 'aggregate', label: 'Aggregate (snake-like flickering = AGG)' },
+];
+
+interface MatchRow {
+ mineral: Mineral;
+ riOverlap: boolean;
+ birefringenceOverlap: boolean;
+}
+
+const SIGN_LABEL: Record = {
+ '+': 'positive',
+ '-': 'negative',
+ '+/-': 'either sign (variable)',
+ 'n/a': '— not applicable —',
+};
+
+export function OpticSignReasoner() {
+ const [minerals, setMinerals] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [dbError, setDbError] = useState(null);
+
+ const [character, setCharacter] = useState('uniaxial');
+ const [omega, setOmega] = useState('');
+ const [epsilon, setEpsilon] = useState('');
+ const [alpha, setAlpha] = useState('');
+ const [beta, setBeta] = useState('');
+ const [gamma, setGamma] = useState('');
+ const tolerance = 0.005;
+
+ useEffect(() => {
+ let mounted = true;
+ (async () => {
+ try {
+ const all = await getAllMinerals();
+ if (!mounted) return;
+ setMinerals(all);
+ } catch (err) {
+ if (!mounted) return;
+ setDbError(err instanceof Error ? err.message : 'Database load failed');
+ } finally {
+ if (mounted) setLoading(false);
+ }
+ })();
+ return () => {
+ mounted = false;
+ };
+ }, []);
+
+ const computed = useMemo(() => {
+ if (character === 'isotropic') {
+ return {
+ sign: 'n/a' as OpticSign,
+ birefringence: 0,
+ twoV: null as number | null,
+ riCentre: null as number | null,
+ };
+ }
+ if (character === 'aggregate') {
+ return {
+ sign: 'n/a' as OpticSign,
+ birefringence: null,
+ twoV: null,
+ riCentre: null,
+ };
+ }
+ if (character === 'uniaxial') {
+ const o = parseFloat(omega);
+ const e = parseFloat(epsilon);
+ if (Number.isNaN(o) || Number.isNaN(e)) {
+ return { sign: 'n/a' as OpticSign, birefringence: null, twoV: null, riCentre: null };
+ }
+ return {
+ sign: uniaxialSign(o, e),
+ birefringence: Math.abs(e - o),
+ twoV: null,
+ riCentre: (o + e) / 2,
+ };
+ }
+ // biaxial
+ const a = parseFloat(alpha);
+ const b = parseFloat(beta);
+ const g = parseFloat(gamma);
+ if (Number.isNaN(a) || Number.isNaN(g)) {
+ return { sign: 'n/a' as OpticSign, birefringence: null, twoV: null, riCentre: null };
+ }
+ const aSorted = Math.min(a, g);
+ const gSorted = Math.max(a, g);
+ const bUsed = Number.isNaN(b) ? (aSorted + gSorted) / 2 : b;
+ return {
+ sign: biaxialSign(aSorted, bUsed, gSorted),
+ birefringence: gSorted - aSorted,
+ twoV: biaxial2V(aSorted, bUsed, gSorted),
+ riCentre: (aSorted + gSorted) / 2,
+ };
+ }, [character, omega, epsilon, alpha, beta, gamma]);
+
+ const matches = useMemo(() => {
+ if (minerals.length === 0) return [];
+ const out: MatchRow[] = [];
+ const obsSign: OpticSign = computed.sign;
+ const ri = computed.riCentre;
+ const br = computed.birefringence;
+
+ for (const m of minerals) {
+ const ref = parseOpticalCharacter(m.optical_character);
+ if (!characterMatches(character, obsSign, ref)) continue;
+
+ let riOk = true;
+ if (ri !== null && m.ri_min !== undefined && m.ri_max !== undefined) {
+ riOk =
+ ri >= m.ri_min - tolerance && ri <= m.ri_max + tolerance;
+ }
+
+ let brOk = true;
+ if (br !== null && br > 0 && m.birefringence !== undefined) {
+ brOk = Math.abs(m.birefringence - br) <= 0.01;
+ }
+
+ if (!riOk) continue;
+ out.push({
+ mineral: m,
+ riOverlap:
+ ri !== null && m.ri_min !== undefined && m.ri_max !== undefined && riOk,
+ birefringenceOverlap: brOk && br !== null && br > 0,
+ });
+ }
+
+ out.sort((x, y) => {
+ // Items with both RI and birefringence overlap rank highest.
+ const xs = (x.riOverlap ? 2 : 0) + (x.birefringenceOverlap ? 1 : 0);
+ const ys = (y.riOverlap ? 2 : 0) + (y.birefringenceOverlap ? 1 : 0);
+ if (ys !== xs) return ys - xs;
+ return x.mineral.name.localeCompare(y.mineral.name);
+ });
+ return out;
+ }, [minerals, character, computed]);
+
+ const { paged, page, setPage, totalPages } = usePagination(matches, 10);
+
+ return (
+
+
+ Pick what you saw in the polariscope. For uniaxial gems, enter ω and ε from the
+ refractometer; for biaxial, enter α and γ (β is optional). The reasoner derives optic
+ sign, birefringence, and 2V where defined, then ranks candidate species.
+
+
+
+
+ setCharacter(v as OpticCharacterKind)}
+ />
+
+
+
+ {character === 'uniaxial' && (
+
+
+
+
+
+
+
+
+ )}
+
+ {character === 'biaxial' && (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Computed-output panel */}
+ {character !== 'isotropic' && character !== 'aggregate' && (
+
+
Computed
+
+ Optic sign:{' '}
+ {SIGN_LABEL[computed.sign]}
+
+ {computed.birefringence !== null && computed.birefringence > 0 && (
+
+ Birefringence: {computed.birefringence.toFixed(3)}
+
+ )}
+ {computed.twoV !== null && (
+
+ 2V (Vz, Mallard): {computed.twoV.toFixed(1)}° {' '}
+
+ (acute 2V around γ for biaxial+, around α for biaxial−)
+
+
+ )}
+
+ )}
+
+ {/* Candidate list */}
+ {dbError && (
+
+ Database unavailable — candidate ranking disabled. ({dbError})
+
+ )}
+ {loading ? (
+
+ Loading mineral database…
+
+ ) : matches.length === 0 ? (
+
+ No species in the database match these readings. Try widening the tolerance, double-check
+ the optic character, or confirm the RI readings.
+
+ ) : (
+
+
+ {matches.length} candidate species
+
+ {paged.map(({ mineral, riOverlap, birefringenceOverlap }) => (
+
+
+
+
+ {mineral.name}
+
+ {mineral.optical_character && (
+
+ ({mineral.optical_character})
+
+ )}
+
+
+ {riOverlap && (
+
+ RI ✓
+
+ )}
+ {birefringenceOverlap && (
+
+ BR ✓
+
+ )}
+
+
+ {(mineral.ri_min !== undefined || mineral.birefringence !== undefined) && (
+
+ {mineral.ri_min !== undefined &&
+ mineral.ri_max !== undefined &&
+ `RI ${mineral.ri_min.toFixed(3)}–${mineral.ri_max.toFixed(3)}`}
+ {mineral.birefringence !== undefined &&
+ ` · BR ${mineral.birefringence.toFixed(3)}`}
+
+ )}
+
+ ))}
+ {totalPages > 1 && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/src/components/optical/OpticalTools.tsx b/src/components/optical/OpticalTools.tsx
index a0bed50..4ddb4b0 100644
--- a/src/components/optical/OpticalTools.tsx
+++ b/src/components/optical/OpticalTools.tsx
@@ -7,6 +7,7 @@ import { DichroscopeResults } from './DichroscopeResults';
import { PolariscopeGuide } from './PolariscopeGuide';
import { RefractometerSimulator } from './RefractometerSimulator';
import { PleochroismReasoner } from './PleochroismReasoner';
+import { OpticSignReasoner } from './OpticSignReasoner';
import { ToolSection } from '../ui/ToolSection';
const ICON_PATHS = {
@@ -14,6 +15,7 @@ const ICON_PATHS = {
adjust: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
beaker: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z',
prism: 'M12 2L2 22h20L12 2z M12 2v20',
+ axes: 'M4 4l16 16M20 4L4 20M12 2v20M2 12h20',
};
export function OpticalTools() {
@@ -59,6 +61,16 @@ export function OpticalTools() {
+
+
+
+
{/* Learn More section */}
Learn More
diff --git a/src/components/optical/index.ts b/src/components/optical/index.ts
index 88642e8..936709d 100644
--- a/src/components/optical/index.ts
+++ b/src/components/optical/index.ts
@@ -5,4 +5,6 @@
export { DichroscopeResults } from './DichroscopeResults';
export { PolariscopeGuide } from './PolariscopeGuide';
export { RefractometerSimulator } from './RefractometerSimulator';
+export { PleochroismReasoner } from './PleochroismReasoner';
+export { OpticSignReasoner } from './OpticSignReasoner';
export { OpticalTools } from './OpticalTools';
diff --git a/src/lib/optic-sign/optic-character.test.ts b/src/lib/optic-sign/optic-character.test.ts
new file mode 100644
index 0000000..2438c78
--- /dev/null
+++ b/src/lib/optic-sign/optic-character.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect } from 'vitest';
+import {
+ parseOpticalCharacter,
+ uniaxialSign,
+ biaxialSign,
+ biaxial2V,
+ characterMatches,
+} from './optic-character';
+
+describe('parseOpticalCharacter', () => {
+ it('parses isotropic', () => {
+ expect(parseOpticalCharacter('Isotropic')).toMatchObject({ kind: 'isotropic', sign: 'n/a' });
+ });
+ it('parses uniaxial positive', () => {
+ expect(parseOpticalCharacter('Uniaxial +')).toMatchObject({ kind: 'uniaxial', sign: '+' });
+ });
+ it('parses uniaxial negative', () => {
+ expect(parseOpticalCharacter('Uniaxial -')).toMatchObject({ kind: 'uniaxial', sign: '-' });
+ });
+ it('parses biaxial both signs', () => {
+ expect(parseOpticalCharacter('Biaxial + or -')).toMatchObject({
+ kind: 'biaxial',
+ sign: '+/-',
+ });
+ });
+ it('treats AGG as aggregate', () => {
+ expect(parseOpticalCharacter('AGG').kind).toBe('aggregate');
+ });
+ it('treats opaque metallic as opaque', () => {
+ expect(parseOpticalCharacter('Opaque (metallic)').kind).toBe('opaque');
+ });
+ it('returns unknown for empty string', () => {
+ expect(parseOpticalCharacter('')).toEqual({ kind: 'unknown', sign: 'n/a' });
+ });
+ it('handles "Isotropic to near-isotropic (AGG)" as isotropic', () => {
+ expect(parseOpticalCharacter('Isotropic to near-isotropic (AGG)').kind).toBe('isotropic');
+ });
+});
+
+describe('uniaxialSign', () => {
+ it('returns + when epsilon > omega (e.g. quartz: 1.544 / 1.553)', () => {
+ expect(uniaxialSign(1.544, 1.553)).toBe('+');
+ });
+ it('returns - when epsilon < omega (e.g. ruby: 1.770 / 1.762)', () => {
+ expect(uniaxialSign(1.77, 1.762)).toBe('-');
+ });
+});
+
+describe('biaxialSign', () => {
+ it('returns + when β closer to α (forsterite-end peridot ~ 1.635 / 1.651 / 1.670)', () => {
+ // γ-β = 0.019, β-α = 0.016 → β closer to α → biaxial positive
+ expect(biaxialSign(1.635, 1.651, 1.67)).toBe('+');
+ });
+ it('returns - when β closer to γ', () => {
+ // γ-β = 0.005, β-α = 0.030 → β closer to γ → biaxial negative
+ expect(biaxialSign(1.5, 1.53, 1.535)).toBe('-');
+ });
+});
+
+describe('biaxial2V', () => {
+ it('computes a sane positive angle from valid indices', () => {
+ const v = biaxial2V(1.635, 1.651, 1.67);
+ expect(v).not.toBeNull();
+ expect(v!).toBeGreaterThan(0);
+ expect(v!).toBeLessThan(90);
+ });
+ it('returns null on degenerate input', () => {
+ expect(biaxial2V(1.5, 1.5, 1.5)).toBeNull();
+ });
+});
+
+describe('characterMatches', () => {
+ it('matches identical character + sign', () => {
+ expect(
+ characterMatches('uniaxial', '+', { kind: 'uniaxial', sign: '+' }),
+ ).toBe(true);
+ });
+ it('rejects character mismatch', () => {
+ expect(
+ characterMatches('uniaxial', '+', { kind: 'biaxial', sign: '+' }),
+ ).toBe(false);
+ });
+ it('rejects sign mismatch', () => {
+ expect(
+ characterMatches('uniaxial', '+', { kind: 'uniaxial', sign: '-' }),
+ ).toBe(false);
+ });
+ it('accepts +/- references for either sign', () => {
+ expect(
+ characterMatches('uniaxial', '+', { kind: 'uniaxial', sign: '+/-' }),
+ ).toBe(true);
+ expect(
+ characterMatches('uniaxial', '-', { kind: 'uniaxial', sign: '+/-' }),
+ ).toBe(true);
+ });
+ it('accepts unknown observed sign as wildcard', () => {
+ expect(
+ characterMatches('biaxial', 'n/a', { kind: 'biaxial', sign: '-' }),
+ ).toBe(true);
+ });
+});
diff --git a/src/lib/optic-sign/optic-character.ts b/src/lib/optic-sign/optic-character.ts
new file mode 100644
index 0000000..b7b5ace
--- /dev/null
+++ b/src/lib/optic-sign/optic-character.ts
@@ -0,0 +1,138 @@
+/**
+ * Optic-character + 2V reasoner.
+ *
+ * Inputs from the user:
+ * - Optic character observed in the polariscope (isotropic / uniaxial / biaxial / aggregate).
+ * - For uniaxial: ω and ε refractive indices.
+ * - For biaxial: α, β, γ refractive indices (β optional — derived if missing).
+ *
+ * Outputs:
+ * - Computed optic sign and birefringence.
+ * - 2V band for biaxial gems (Mallard formula approximation).
+ * - Ranked candidate species filtered against the database, parsing the
+ * `optical_character` text field for each family.
+ *
+ * The mineral database stores `optical_character` as freeform text — see
+ * MineralFamily.optical_character. Values seen in the source YAML include:
+ * "Isotropic", "Uniaxial +", "Uniaxial -", "Uniaxial - (occasionally +)",
+ * "Biaxial +", "Biaxial -", "Biaxial + or -", "AGG", "Aggregate",
+ * "Isotropic to near-isotropic (AGG)", "Opaque (metallic)".
+ *
+ * This pure-TS layer parses that text into a discriminated `OpticalCharacter`
+ * so we don't need to touch the SQLite schema for v1.
+ */
+
+export type OpticCharacterKind =
+ | 'isotropic'
+ | 'uniaxial'
+ | 'biaxial'
+ | 'aggregate'
+ | 'opaque'
+ | 'unknown';
+
+export type OpticSign = '+' | '-' | '+/-' | 'n/a';
+
+export interface OpticalCharacter {
+ kind: OpticCharacterKind;
+ sign: OpticSign;
+ /** Original raw text from the database. */
+ raw?: string;
+}
+
+/**
+ * Parse the freeform `optical_character` field into a discriminated record.
+ *
+ * Heuristic rules (case-insensitive):
+ * - Contains "isotropic" → kind isotropic, sign n/a.
+ * - Contains "uniaxial" → kind uniaxial. Sign from "+ or -" / "+" / "-".
+ * - Contains "biaxial" → kind biaxial. Sign from "+ or -" / "+" / "-".
+ * - Contains "aggregate" / "AGG" → kind aggregate.
+ * - Contains "opaque" → kind opaque.
+ * - Otherwise unknown.
+ */
+export function parseOpticalCharacter(text: string | undefined | null): OpticalCharacter {
+ if (!text || !text.trim()) return { kind: 'unknown', sign: 'n/a' };
+ const raw = text.trim();
+ const lower = raw.toLowerCase();
+
+ let sign: OpticSign = 'n/a';
+ if (/[+\-] *or *[+\-]|both signs|\+\/-/.test(lower)) sign = '+/-';
+ else if (/(?:^|[^a-z])(uniaxial|biaxial)\s*\+/.test(lower)) sign = '+';
+ else if (/(?:^|[^a-z])(uniaxial|biaxial)\s*-/.test(lower)) sign = '-';
+
+ if (lower.includes('isotropic')) return { kind: 'isotropic', sign: 'n/a', raw };
+ if (lower.includes('uniaxial')) return { kind: 'uniaxial', sign, raw };
+ if (lower.includes('biaxial')) return { kind: 'biaxial', sign, raw };
+ if (lower.includes('aggregate') || /\bagg\b/.test(lower)) {
+ return { kind: 'aggregate', sign: 'n/a', raw };
+ }
+ if (lower.includes('opaque')) return { kind: 'opaque', sign: 'n/a', raw };
+ return { kind: 'unknown', sign: 'n/a', raw };
+}
+
+/**
+ * Determine optic sign from uniaxial RI readings.
+ * Uniaxial positive: ε > ω (epsilon, the extraordinary ray, is larger).
+ * Uniaxial negative: ε < ω.
+ */
+export function uniaxialSign(omega: number, epsilon: number): OpticSign {
+ const diff = epsilon - omega;
+ if (Math.abs(diff) < 0.001) return 'n/a';
+ return diff > 0 ? '+' : '-';
+}
+
+/**
+ * Determine optic sign from biaxial RI readings.
+ * Biaxial positive: γ - β > β - α (β closer to α).
+ * Biaxial negative: γ - β < β - α (β closer to γ).
+ */
+export function biaxialSign(alpha: number, beta: number, gamma: number): OpticSign {
+ if (alpha > gamma) {
+ // Caller swapped them; fix.
+ [alpha, gamma] = [gamma, alpha];
+ }
+ const upper = gamma - beta;
+ const lower = beta - alpha;
+ if (Math.abs(upper - lower) < 0.0005) return '+/-';
+ return upper > lower ? '+' : '-';
+}
+
+/**
+ * Approximate 2V using the Mallard formula:
+ * cos²(V_z) = (γ² · (β² - α²)) / (β² · (γ² - α²))
+ *
+ * Returns Vz in degrees (the optic axial angle measured from γ for biaxial+
+ * or from α for biaxial-). Caller decides which axis the angle refers to.
+ *
+ * Returns null if the inputs are degenerate.
+ */
+export function biaxial2V(alpha: number, beta: number, gamma: number): number | null {
+ if (alpha > gamma) [alpha, gamma] = [gamma, alpha];
+ if (gamma <= alpha || beta <= alpha || beta >= gamma) return null;
+ const num = gamma * gamma * (beta * beta - alpha * alpha);
+ const den = beta * beta * (gamma * gamma - alpha * alpha);
+ if (den === 0) return null;
+ const cosSq = num / den;
+ if (cosSq < 0 || cosSq > 1) return null;
+ const vz = Math.acos(Math.sqrt(cosSq)) * (180 / Math.PI);
+ return vz;
+}
+
+/**
+ * Compatibility check between an observed character and a parsed reference.
+ * Returns true if the species is compatible with the user's observation.
+ */
+export function characterMatches(
+ observedKind: OpticCharacterKind,
+ observedSign: OpticSign | undefined,
+ reference: OpticalCharacter,
+): boolean {
+ if (reference.kind === 'unknown') return false;
+ // Aggregate often masks other characters — keep flexible.
+ if (observedKind === 'aggregate') return reference.kind === 'aggregate';
+ if (reference.kind === 'aggregate') return false;
+ if (reference.kind !== observedKind) return false;
+ if (!observedSign || observedSign === 'n/a' || reference.sign === 'n/a') return true;
+ if (reference.sign === '+/-' || observedSign === '+/-') return true;
+ return reference.sign === observedSign;
+}
diff --git a/src/lib/spectroscope/match-bands.test.ts b/src/lib/spectroscope/match-bands.test.ts
new file mode 100644
index 0000000..0eaad92
--- /dev/null
+++ b/src/lib/spectroscope/match-bands.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from 'vitest';
+import { matchBands } from './match-bands';
+
+describe('matchBands', () => {
+ it('ranks ruby top when chrome doublet observed', () => {
+ const r = matchBands([693, 555, 476], 5);
+ expect(r[0].reference.name).toMatch(/Ruby/);
+ expect(r[0].hasSelective).toBe(true);
+ });
+
+ it('ranks almandine top for the 504 / 527 / 576 trio', () => {
+ const r = matchBands([504, 527, 576], 4);
+ expect(r[0].reference.name).toMatch(/Almandine/);
+ });
+
+ it('returns empty for no observations', () => {
+ expect(matchBands([], 5)).toEqual([]);
+ });
+
+ it('respects tolerance — 700 nm does not match the 692 ruby line at ±5', () => {
+ const r = matchBands([700], 5);
+ expect(r.find((m) => m.reference.name.match(/Ruby/))).toBeUndefined();
+ });
+
+ it('matches blue sapphire on the 450 nm Fe³⁺ line', () => {
+ const r = matchBands([450], 4);
+ expect(r[0].reference.name).toMatch(/sapphire/i);
+ });
+
+ it('matches zircon on the 653 nm uranium line', () => {
+ const r = matchBands([653], 4);
+ expect(r[0].reference.name).toMatch(/Zircon/);
+ });
+});
diff --git a/src/lib/spectroscope/match-bands.ts b/src/lib/spectroscope/match-bands.ts
new file mode 100644
index 0000000..56d6eea
--- /dev/null
+++ b/src/lib/spectroscope/match-bands.ts
@@ -0,0 +1,104 @@
+/**
+ * Pure matcher for the Spectroscope Band Matcher widget.
+ *
+ * Inputs: a set of observed band wavelengths (the user ticks them off in the
+ * UI) plus an optional tolerance in nm. For each species in the reference
+ * table, we count how many bands match, weighted by intensity and
+ * "selective" flag, and divide by the species' total band weight to get a
+ * coverage score. The result is a ranked candidate list.
+ */
+
+import {
+ SPECTROSCOPE_REFERENCE,
+ type AbsorptionBand,
+ type SpectroscopeReference,
+} from './reference-bands';
+
+const INTENSITY_WEIGHT = { weak: 1, moderate: 2, strong: 3 } as const;
+
+export interface BandMatch {
+ reference: SpectroscopeReference;
+ /** 0..1 — fraction of the species' weighted bands the user observed. */
+ coverage: number;
+ /** Bands that matched (with the observed wavelength that triggered them). */
+ matched: { observed: number; band: AbsorptionBand }[];
+ /** True if at least one matched band was flagged `selective`. */
+ hasSelective: boolean;
+ /** Reasoning text for the UI. */
+ reason: string;
+}
+
+function bandWavelengths(b: AbsorptionBand): number[] {
+ return [b.wavelength, ...(b.also ?? [])];
+}
+
+function nearest(observed: number, target: number): number {
+ return Math.abs(observed - target);
+}
+
+/**
+ * Match observed bands against the reference table.
+ * @param observed list of wavelengths the user ticked
+ * @param tolerance ± nm window for considering a band "matched" (default 5 nm)
+ */
+export function matchBands(observed: number[], tolerance = 5): BandMatch[] {
+ if (observed.length === 0) return [];
+
+ const out: BandMatch[] = [];
+ for (const reference of SPECTROSCOPE_REFERENCE) {
+ let totalWeight = 0;
+ let matchedWeight = 0;
+ let hasSelective = false;
+ const matched: BandMatch['matched'] = [];
+
+ for (const band of reference.bands) {
+ const w = INTENSITY_WEIGHT[band.intensity] * (band.selective ? 2 : 1);
+ totalWeight += w;
+
+ const targets = bandWavelengths(band);
+ let bestObs: number | null = null;
+ let bestDist = Infinity;
+ for (const obs of observed) {
+ for (const target of targets) {
+ const d = nearest(obs, target);
+ if (d <= tolerance && d < bestDist) {
+ bestDist = d;
+ bestObs = obs;
+ }
+ }
+ }
+ if (bestObs !== null) {
+ matchedWeight += w;
+ matched.push({ observed: bestObs, band });
+ if (band.selective) hasSelective = true;
+ }
+ }
+
+ if (matchedWeight === 0) continue;
+ const coverage = matchedWeight / totalWeight;
+
+ const reasonParts: string[] = [];
+ reasonParts.push(`${matched.length} of ${reference.bands.length} reference bands matched.`);
+ if (hasSelective) {
+ reasonParts.push('Includes a selective (diagnostic) band.');
+ }
+ if (reference.observationNotes) {
+ reasonParts.push(reference.observationNotes);
+ }
+
+ out.push({
+ reference,
+ coverage,
+ matched,
+ hasSelective,
+ reason: reasonParts.join(' '),
+ });
+ }
+
+ out.sort((a, b) => {
+ // Selective matches dominate.
+ if (a.hasSelective !== b.hasSelective) return a.hasSelective ? -1 : 1;
+ return b.coverage - a.coverage;
+ });
+ return out;
+}
diff --git a/src/lib/spectroscope/reference-bands.ts b/src/lib/spectroscope/reference-bands.ts
new file mode 100644
index 0000000..02b4676
--- /dev/null
+++ b/src/lib/spectroscope/reference-bands.ts
@@ -0,0 +1,217 @@
+/**
+ * Reference absorption-band data for the most-tested gem species.
+ *
+ * Wavelengths are in nanometres, sourced from standard gemmological reference
+ * texts (GIA Coloured Stones manual, Hodgkinson's Visual Optics, Webster's
+ * Gems). Intensity is qualitative: `weak` (faint band, easy to miss),
+ * `moderate` (clearly visible), `strong` (sharp dark line).
+ *
+ * `selective` flags bands that uniquely identify the species (e.g. the
+ * 504 nm line in almandine garnet, the chrome doublet at 692/694 nm in ruby).
+ *
+ * Stored separately from the SQLite mineral database — adding a column there
+ * is a multi-day data-authoring effort. This TypeScript table is the
+ * pragmatic v1 implementation; a future migration can populate
+ * `minerals.absorption_bands_json` from this same data.
+ */
+
+export interface AbsorptionBand {
+ /** Wavelength in nanometres. Use a single number for sharp lines; for broad bands list the centre. */
+ wavelength: number;
+ /** Optional secondary wavelength for doublets / triplets. */
+ also?: number[];
+ intensity: 'weak' | 'moderate' | 'strong';
+ /** Cause of the band (Cr³⁺, Fe²⁺, didymium, etc.). */
+ cause?: string;
+ /** True when this band alone is highly diagnostic. */
+ selective?: boolean;
+ /** Short note explaining the band. */
+ note?: string;
+}
+
+export interface SpectroscopeReference {
+ /** Mineral family id, matches `mineral_families.id`. */
+ familyId: string;
+ /** Display name. */
+ name: string;
+ bands: AbsorptionBand[];
+ /** Light-source caveats or viewing tips. */
+ observationNotes?: string;
+}
+
+export const SPECTROSCOPE_REFERENCE: SpectroscopeReference[] = [
+ // Chromophore: Cr³⁺ (chromium) — ruby, red spinel, alexandrite, emerald.
+ {
+ familyId: 'corundum',
+ name: 'Ruby (Cr-corundum)',
+ bands: [
+ { wavelength: 692, also: [694], intensity: 'strong', cause: 'Cr³⁺ doublet (R-lines)', selective: true, note: 'Sharp red doublet at 692/694 nm — the textbook ruby fingerprint.' },
+ { wavelength: 660, intensity: 'weak', cause: 'Cr³⁺' },
+ { wavelength: 555, intensity: 'moderate', cause: 'Cr³⁺ broad absorption', note: 'Wide green absorption band.' },
+ { wavelength: 476, intensity: 'moderate', cause: 'Cr³⁺' },
+ { wavelength: 468, intensity: 'moderate', cause: 'Cr³⁺' },
+ ],
+ observationNotes: 'Use a strong incandescent or fibre-optic source; the doublet is much easier in the red transmission band.',
+ },
+ {
+ familyId: 'spinel',
+ name: 'Red spinel (Cr-spinel)',
+ bands: [
+ { wavelength: 685, intensity: 'strong', cause: 'Cr³⁺ "organ-pipe"', selective: true, note: 'Series of fine lines 670–690 nm called the organ-pipe spectrum.' },
+ { wavelength: 656, intensity: 'moderate', cause: 'Cr³⁺' },
+ { wavelength: 540, intensity: 'moderate', cause: 'Cr³⁺' },
+ ],
+ },
+ {
+ familyId: 'chrysoberyl',
+ name: 'Alexandrite (Cr-chrysoberyl)',
+ bands: [
+ { wavelength: 680, also: [678], intensity: 'strong', cause: 'Cr³⁺ doublet', selective: true },
+ { wavelength: 645, intensity: 'weak', cause: 'Cr³⁺' },
+ { wavelength: 580, intensity: 'moderate', cause: 'Cr³⁺' },
+ { wavelength: 468, intensity: 'moderate', cause: 'Cr³⁺' },
+ ],
+ observationNotes: 'Colour change is best seen by switching incandescent ↔ daylight, but the spectrum is identical in both.',
+ },
+ {
+ familyId: 'beryl',
+ name: 'Emerald (Cr-beryl)',
+ bands: [
+ { wavelength: 683, also: [680], intensity: 'strong', cause: 'Cr³⁺ doublet', selective: true },
+ { wavelength: 637, intensity: 'weak', cause: 'Cr³⁺' },
+ { wavelength: 606, intensity: 'weak', cause: 'Cr³⁺' },
+ { wavelength: 477, intensity: 'moderate', cause: 'Cr³⁺ broad absorption' },
+ ],
+ observationNotes: 'Polariscope ω-ray gives strongest red doublet.',
+ },
+ // Chromophore: Fe³⁺ — blue sapphire, aquamarine.
+ {
+ familyId: 'corundum-blue',
+ name: 'Blue sapphire (Fe-corundum)',
+ bands: [
+ { wavelength: 450, intensity: 'strong', cause: 'Fe³⁺', selective: true, note: 'Diagnostic 450 nm line in iron-rich blue sapphires.' },
+ { wavelength: 460, intensity: 'moderate', cause: 'Fe³⁺' },
+ { wavelength: 470, intensity: 'weak', cause: 'Fe³⁺' },
+ ],
+ observationNotes: 'Synthetic flame-fusion sapphire often lacks the 450 nm line — useful diagnostic.',
+ },
+ // Almandine garnet — Fe²⁺.
+ {
+ familyId: 'almandine',
+ name: 'Almandine garnet (Fe²⁺)',
+ bands: [
+ { wavelength: 504, intensity: 'strong', cause: 'Fe²⁺', selective: true, note: 'The 504 nm line is the strongest Fe²⁺ band in almandine.' },
+ { wavelength: 527, intensity: 'strong', cause: 'Fe²⁺' },
+ { wavelength: 576, intensity: 'moderate', cause: 'Fe²⁺' },
+ { wavelength: 423, intensity: 'weak', cause: 'Fe²⁺' },
+ ],
+ observationNotes: 'Strongest broad-band Fe²⁺ spectrum among gems; visible even in dark stones.',
+ },
+ // Didymium-bearing rare-earth gems.
+ {
+ familyId: 'apatite',
+ name: 'Yellow apatite (didymium)',
+ bands: [
+ { wavelength: 580, intensity: 'strong', cause: 'didymium (Pr+Nd)', selective: true },
+ { wavelength: 525, intensity: 'moderate', cause: 'didymium' },
+ { wavelength: 512, intensity: 'moderate', cause: 'didymium' },
+ { wavelength: 491, intensity: 'weak', cause: 'didymium' },
+ ],
+ observationNotes: 'Sharp didymium lines also seen in some sphene and rare yellow zircon.',
+ },
+ {
+ familyId: 'sphene',
+ name: 'Sphene / titanite',
+ bands: [
+ { wavelength: 586, intensity: 'moderate', cause: 'didymium' },
+ { wavelength: 536, intensity: 'weak', cause: 'didymium' },
+ ],
+ },
+ // Zircon — characteristic uranium-bearing spectrum.
+ {
+ familyId: 'zircon',
+ name: 'Zircon (high type)',
+ bands: [
+ { wavelength: 653, intensity: 'strong', cause: 'U⁴⁺', selective: true, note: 'Classic uranium-line at 653.5 nm — strongest in heat-treated blue zircon.' },
+ { wavelength: 659, intensity: 'moderate', cause: 'U⁴⁺' },
+ { wavelength: 691, intensity: 'weak', cause: 'U⁴⁺' },
+ { wavelength: 588, intensity: 'weak', cause: 'U⁴⁺' },
+ { wavelength: 562, intensity: 'weak', cause: 'U⁴⁺' },
+ { wavelength: 537, intensity: 'weak', cause: 'U⁴⁺' },
+ { wavelength: 484, intensity: 'weak', cause: 'U⁴⁺' },
+ ],
+ observationNotes: 'Low-type (metamict) zircon shows weakened or no spectrum.',
+ },
+ // Peridot — Fe²⁺ triplet.
+ {
+ familyId: 'peridot',
+ name: 'Peridot (forsterite-fayalite)',
+ bands: [
+ { wavelength: 493, intensity: 'strong', cause: 'Fe²⁺', selective: true, note: '"Three-lock-and-key" pattern: 493 / 473 / 453 nm.' },
+ { wavelength: 473, intensity: 'strong', cause: 'Fe²⁺' },
+ { wavelength: 453, intensity: 'strong', cause: 'Fe²⁺' },
+ ],
+ },
+ // Diamond — N3 / N2 systems.
+ {
+ familyId: 'diamond',
+ name: 'Cape-series diamond (N3)',
+ bands: [
+ { wavelength: 415, intensity: 'strong', cause: 'N3 centre', selective: true, note: 'Cape line at 415.5 nm — diagnostic of natural type-Ia diamond.' },
+ { wavelength: 478, intensity: 'weak', cause: 'N3 / H3' },
+ { wavelength: 504, intensity: 'weak', cause: 'H3 (irradiated)' },
+ ],
+ observationNotes: 'Best seen at low temperature with a strong UV-blocked white source.',
+ },
+ // Synthetic/treated diamond — different N-V centres.
+ // Tourmaline — Cr/Fe/Mn pleochroic.
+ {
+ familyId: 'tourmaline',
+ name: 'Cr-tourmaline (chrome dravite)',
+ bands: [
+ { wavelength: 685, intensity: 'moderate', cause: 'Cr³⁺ doublet', note: 'Less sharp than ruby.' },
+ { wavelength: 460, intensity: 'moderate', cause: 'Cr³⁺' },
+ ],
+ },
+ // Demantoid garnet — Cr + Fe.
+ {
+ familyId: 'andradite',
+ name: 'Demantoid garnet',
+ bands: [
+ { wavelength: 444, intensity: 'strong', cause: 'Fe³⁺', selective: true, note: 'Diagnostic Fe³⁺ band at 440 nm distinguishes demantoid from chrysoberyl/peridot.' },
+ { wavelength: 622, intensity: 'weak', cause: 'Cr³⁺' },
+ { wavelength: 685, intensity: 'weak', cause: 'Cr³⁺' },
+ ],
+ },
+ // Chrysoberyl yellow — Fe³⁺.
+ {
+ familyId: 'chrysoberyl-yellow',
+ name: 'Yellow chrysoberyl (Fe)',
+ bands: [
+ { wavelength: 444, intensity: 'strong', cause: 'Fe³⁺', selective: true },
+ ],
+ },
+ // Aquamarine — light Fe²⁺.
+ {
+ familyId: 'beryl-aqua',
+ name: 'Aquamarine (Fe-beryl)',
+ bands: [
+ { wavelength: 537, intensity: 'weak', cause: 'Fe²⁺' },
+ { wavelength: 456, intensity: 'weak', cause: 'Fe³⁺' },
+ { wavelength: 427, intensity: 'weak', cause: 'Fe³⁺' },
+ ],
+ observationNotes: 'Spectrum is faint; lighter stones may show no bands.',
+ },
+];
+
+/** All distinct wavelengths in the reference set, sorted ascending. */
+export function getAllReferenceBands(): number[] {
+ const seen = new Set();
+ for (const ref of SPECTROSCOPE_REFERENCE) {
+ for (const b of ref.bands) {
+ seen.add(b.wavelength);
+ if (b.also) for (const w of b.also) seen.add(w);
+ }
+ }
+ return Array.from(seen).sort((a, b) => a - b);
+}
diff --git a/src/lib/treatments/wizard.test.ts b/src/lib/treatments/wizard.test.ts
new file mode 100644
index 0000000..472ebdb
--- /dev/null
+++ b/src/lib/treatments/wizard.test.ts
@@ -0,0 +1,93 @@
+import { describe, it, expect } from 'vitest';
+import { runWizard, cluesForKind, CLUES } from './wizard';
+
+describe('runWizard', () => {
+ it('flags heat treatment when silk halos and chalky SWUV observed in corundum', () => {
+ const verdicts = runWizard({
+ gemKind: 'corundum',
+ selectedClueIds: ['silk-halo', 'chalky-swuv'],
+ });
+ const heat = verdicts.find((v) => v.treatment === 'heat');
+ expect(heat).toBeDefined();
+ expect(heat!.confidence).toMatch(/likely|very likely/);
+ expect(heat!.supportingClueIds).toContain('silk-halo');
+ expect(heat!.supportingClueIds).toContain('chalky-swuv');
+ });
+
+ it('rules out heat when sharp intact silk is observed', () => {
+ const verdicts = runWizard({
+ gemKind: 'corundum',
+ selectedClueIds: ['rutile-silk-intact'],
+ });
+ const heat = verdicts.find((v) => v.treatment === 'heat');
+ expect(heat).toBeDefined();
+ expect(heat!.score).toBeLessThan(0);
+ expect(heat!.contradictingClueIds).toContain('rutile-silk-intact');
+ });
+
+ it('flags glass / oil filling when flash effect and surface bubbles seen', () => {
+ const verdicts = runWizard({
+ gemKind: 'emerald',
+ selectedClueIds: ['flash-effect', 'surface-bubbles', 'sweat-test'],
+ });
+ const top = verdicts[0];
+ expect(['oil-resin', 'glass-filling']).toContain(top.treatment);
+ expect(top.confidence).toBe('very likely');
+ });
+
+ it('flags HPHT for diamond decolourisation', () => {
+ const verdicts = runWizard({
+ gemKind: 'diamond',
+ selectedClueIds: ['diamond-brown-to-colourless', 'graining-strong'],
+ });
+ expect(verdicts[0].treatment).toBe('hpht');
+ expect(verdicts[0].confidence).toBe('very likely');
+ });
+
+ it('skips clues that do not apply to the chosen gem kind', () => {
+ const verdicts = runWizard({
+ gemKind: 'topaz',
+ selectedClueIds: ['silk-halo', 'colour-fades-light'],
+ });
+ expect(verdicts.find((v) => v.treatment === 'heat')).toBeUndefined();
+ const irr = verdicts.find((v) => v.treatment === 'irradiation');
+ expect(irr).toBeDefined();
+ expect(irr!.supportingClueIds).toContain('colour-fades-light');
+ });
+
+ it('returns empty list when no clues selected', () => {
+ expect(runWizard({ gemKind: 'corundum', selectedClueIds: [] })).toEqual([]);
+ });
+
+ it('flags bleaching+resin for acid-etched jadeite', () => {
+ const verdicts = runWizard({
+ gemKind: 'jadeite',
+ selectedClueIds: ['jadeite-acid-etch', 'porous-or-low-density'],
+ });
+ const bleach = verdicts.find((v) => v.treatment === 'bleaching');
+ expect(bleach).toBeDefined();
+ expect(bleach!.confidence).toMatch(/likely|very likely/);
+ });
+});
+
+describe('cluesForKind', () => {
+ it('filters out gem-specific clues that do not apply', () => {
+ const topazClues = cluesForKind('topaz');
+ expect(topazClues.find((c) => c.id === 'silk-halo')).toBeUndefined();
+ expect(topazClues.find((c) => c.id === 'colour-fades-light')).toBeDefined();
+ });
+
+ it('returns generic clues regardless of kind', () => {
+ const list = cluesForKind('opal');
+ expect(list.find((c) => c.id === 'iridescent-surface')).toBeDefined();
+ expect(list.find((c) => c.id === 'colour-removed-acetone')).toBeDefined();
+ });
+
+ it('every clue with no applicableTo restriction appears for any gem', () => {
+ const generic = CLUES.filter((c) => !c.applicableTo);
+ const pearlList = cluesForKind('pearl');
+ for (const g of generic) {
+ expect(pearlList.find((c) => c.id === g.id)).toBeDefined();
+ }
+ });
+});
diff --git a/src/lib/treatments/wizard.ts b/src/lib/treatments/wizard.ts
new file mode 100644
index 0000000..1f6378f
--- /dev/null
+++ b/src/lib/treatments/wizard.ts
@@ -0,0 +1,239 @@
+/**
+ * Treatment-detection wizard reasoning.
+ *
+ * Maps observed clues to candidate treatments per species. Evidence-weighted:
+ * each clue contributes a positive (supports), neutral (consistent), or
+ * negative (rules out) signal toward each treatment hypothesis.
+ *
+ * Source: GIA gem identification course material, Hughes' Ruby & Sapphire,
+ * standard CIBJO Blue Book treatment definitions.
+ */
+
+export type GemKind =
+ | 'corundum'
+ | 'emerald'
+ | 'beryl-other'
+ | 'tourmaline'
+ | 'topaz'
+ | 'quartz'
+ | 'amber'
+ | 'turquoise'
+ | 'jadeite'
+ | 'opal'
+ | 'pearl'
+ | 'diamond';
+
+export type TreatmentId =
+ | 'heat'
+ | 'oil-resin'
+ | 'lattice-diffusion'
+ | 'surface-diffusion'
+ | 'irradiation'
+ | 'coating'
+ | 'glass-filling'
+ | 'dye'
+ | 'bleaching'
+ | 'hpht'
+ | 'flux-healing';
+
+export interface ClueDef {
+ id: string;
+ label: string;
+ description?: string;
+ /** Per-treatment effect: positive supports, negative rules out, 0 means no impact. */
+ effects: Partial>;
+ /** Restrict the clue to certain gem kinds (display filter). */
+ applicableTo?: GemKind[];
+}
+
+export const TREATMENT_LABELS: Record = {
+ heat: 'Heat treatment',
+ 'oil-resin': 'Oiling / resin filling',
+ 'lattice-diffusion': 'Lattice (Be / Ti) diffusion',
+ 'surface-diffusion': 'Surface diffusion (colour skin)',
+ irradiation: 'Irradiation',
+ coating: 'Surface coating / lacquer',
+ 'glass-filling': 'Lead-glass filling',
+ dye: 'Dyeing',
+ bleaching: 'Bleaching',
+ hpht: 'HPHT colour modification',
+ 'flux-healing': 'Flux healing of fractures',
+};
+
+export const CLUES: ClueDef[] = [
+ {
+ id: 'silk-halo',
+ label: 'Discoid halos / disrupted silk under magnification',
+ description: 'Snowball-like discs around former rutile silk; classic of heated corundum.',
+ effects: { heat: 3, 'lattice-diffusion': 1 },
+ applicableTo: ['corundum'],
+ },
+ {
+ id: 'rutile-silk-intact',
+ label: 'Sharp, intact fine rutile silk',
+ description: 'Long undamaged silk needles indicate the stone has not been strongly heated.',
+ effects: { heat: -3, 'lattice-diffusion': -2 },
+ applicableTo: ['corundum'],
+ },
+ {
+ id: 'colour-concentration-rim',
+ label: 'Colour concentration at facet edges / culet (rim of colour)',
+ description: 'Colour follows facet edges from a thin diffused layer.',
+ effects: { 'surface-diffusion': 4, 'lattice-diffusion': 2, dye: 1 },
+ applicableTo: ['corundum'],
+ },
+ {
+ id: 'chalky-swuv',
+ label: 'Chalky / cloudy short-wave UV reaction',
+ description: 'Chalky blue-white SW glow develops in heat-treated sapphires (especially geuda-derived).',
+ effects: { heat: 3, 'lattice-diffusion': 2 },
+ applicableTo: ['corundum'],
+ },
+ {
+ id: 'flash-effect',
+ label: 'Flash effect (blue/orange flash on tilt)',
+ description: 'Blue / orange flash of light along fractures viewed at certain angles.',
+ effects: { 'oil-resin': 4, 'glass-filling': 4 },
+ applicableTo: ['emerald', 'corundum'],
+ },
+ {
+ id: 'surface-bubbles',
+ label: 'Round gas bubbles in surface-reaching fissures',
+ description: 'Trapped during glass / resin filling.',
+ effects: { 'glass-filling': 4, 'oil-resin': 2 },
+ applicableTo: ['corundum', 'emerald', 'turquoise'],
+ },
+ {
+ id: 'sweat-test',
+ label: 'Residue / sweating after gentle warming',
+ description: 'Oil or resin sweats out of fissures when stone is warmed.',
+ effects: { 'oil-resin': 4 },
+ applicableTo: ['emerald', 'beryl-other'],
+ },
+ {
+ id: 'colour-zoning-strong',
+ label: 'Strong straight or angular colour zoning',
+ description: 'Natural growth zoning; rules out many homogenised treatments.',
+ effects: { heat: -1, 'lattice-diffusion': -1 },
+ },
+ {
+ id: 'colour-zoning-curved',
+ label: 'Curved (Plato) colour bands',
+ description: 'Curved striae indicate flame-fusion synthesis, not treatment.',
+ effects: {},
+ },
+ {
+ id: 'fingerprint-healed',
+ label: 'Healed fingerprint inclusions / partial healing',
+ description: 'Re-healed fractures from flux-assisted heating.',
+ effects: { 'flux-healing': 3, heat: 2 },
+ applicableTo: ['corundum'],
+ },
+ {
+ id: 'flux-residue',
+ label: 'Yellow / orange flux residue in fissures',
+ description: 'Borax-like residue trapped during flux heating.',
+ effects: { 'flux-healing': 4, heat: 1 },
+ applicableTo: ['corundum'],
+ },
+ {
+ id: 'colour-fades-light',
+ label: 'Colour fades with prolonged light or heat',
+ effects: { irradiation: 3, dye: 2, coating: 2 },
+ applicableTo: ['topaz', 'tourmaline', 'beryl-other', 'quartz', 'turquoise'],
+ },
+ {
+ id: 'colour-removed-acetone',
+ label: 'Colour rubs / dissolves with acetone or alcohol swab',
+ effects: { dye: 4, coating: 3 },
+ },
+ {
+ id: 'iridescent-surface',
+ label: 'Iridescent or oily surface sheen under reflection',
+ effects: { coating: 4 },
+ },
+ {
+ id: 'diamond-brown-to-colourless',
+ label: 'Diamond changed from brown to colourless / yellow → colourless',
+ effects: { hpht: 4 },
+ applicableTo: ['diamond'],
+ },
+ {
+ id: 'graining-strong',
+ label: 'Strong internal graining / strain (HPHT diamond)',
+ effects: { hpht: 2 },
+ applicableTo: ['diamond'],
+ },
+ {
+ id: 'porous-or-low-density',
+ label: 'Porous / chalky surface or unusually low SG',
+ effects: { dye: 2, bleaching: 3, 'oil-resin': 2 },
+ applicableTo: ['turquoise', 'jadeite'],
+ },
+ {
+ id: 'jadeite-acid-etch',
+ label: 'Jadeite — acid-etched / honeycomb surface texture',
+ effects: { bleaching: 4, 'oil-resin': 3 },
+ applicableTo: ['jadeite'],
+ },
+];
+
+export interface WizardCriteria {
+ gemKind: GemKind;
+ selectedClueIds: string[];
+}
+
+export interface TreatmentVerdict {
+ treatment: TreatmentId;
+ label: string;
+ /** Aggregate evidence score; positive favours, negative argues against. */
+ score: number;
+ /** Verbal confidence band based on score. */
+ confidence: 'unlikely' | 'possible' | 'likely' | 'very likely';
+ /** Clues contributing positively to this verdict. */
+ supportingClueIds: string[];
+ /** Clues contributing negatively. */
+ contradictingClueIds: string[];
+}
+
+const ALL_TREATMENTS: TreatmentId[] = Object.keys(TREATMENT_LABELS) as TreatmentId[];
+
+export function runWizard(criteria: WizardCriteria): TreatmentVerdict[] {
+ const verdicts: Record = {} as Record;
+ for (const t of ALL_TREATMENTS) {
+ verdicts[t] = {
+ treatment: t,
+ label: TREATMENT_LABELS[t],
+ score: 0,
+ confidence: 'unlikely',
+ supportingClueIds: [],
+ contradictingClueIds: [],
+ };
+ }
+
+ for (const clueId of criteria.selectedClueIds) {
+ const clue = CLUES.find((c) => c.id === clueId);
+ if (!clue) continue;
+ if (clue.applicableTo && !clue.applicableTo.includes(criteria.gemKind)) continue;
+ for (const [tid, weight] of Object.entries(clue.effects) as [TreatmentId, number][]) {
+ verdicts[tid].score += weight;
+ if (weight > 0) verdicts[tid].supportingClueIds.push(clueId);
+ else if (weight < 0) verdicts[tid].contradictingClueIds.push(clueId);
+ }
+ }
+
+ for (const v of Object.values(verdicts)) {
+ if (v.score >= 6) v.confidence = 'very likely';
+ else if (v.score >= 3) v.confidence = 'likely';
+ else if (v.score >= 1) v.confidence = 'possible';
+ else v.confidence = 'unlikely';
+ }
+
+ return Object.values(verdicts)
+ .filter((v) => v.score > 0 || v.contradictingClueIds.length > 0)
+ .sort((a, b) => b.score - a.score);
+}
+
+export function cluesForKind(kind: GemKind): ClueDef[] {
+ return CLUES.filter((c) => !c.applicableTo || c.applicableTo.includes(kind));
+}
diff --git a/src/lib/uv-fluorescence/parse-fluorescence.test.ts b/src/lib/uv-fluorescence/parse-fluorescence.test.ts
new file mode 100644
index 0000000..162e74e
--- /dev/null
+++ b/src/lib/uv-fluorescence/parse-fluorescence.test.ts
@@ -0,0 +1,62 @@
+import { describe, it, expect } from 'vitest';
+import { parseFluorescence, scoreUvMatch } from './parse-fluorescence';
+
+describe('parseFluorescence', () => {
+ it('returns inert for both bands when text is just "inert"', () => {
+ const fl = parseFluorescence('Inert');
+ expect(fl?.lwuv?.intensity).toBe('inert');
+ expect(fl?.swuv?.intensity).toBe('inert');
+ });
+
+ it('extracts LW colour and intensity', () => {
+ const fl = parseFluorescence('LW: red strong; SW: inert');
+ expect(fl?.lwuv?.color).toBe('red');
+ expect(fl?.lwuv?.intensity).toBe('strong');
+ expect(fl?.swuv?.intensity).toBe('inert');
+ });
+
+ it('handles "long-wave" / "short-wave" wording', () => {
+ const fl = parseFluorescence('Long-wave UV strong red, short-wave weak');
+ expect(fl?.lwuv?.intensity).toBe('strong');
+ expect(fl?.swuv?.intensity).toBe('weak');
+ });
+
+ it('returns null for empty input', () => {
+ expect(parseFluorescence('')).toBeNull();
+ expect(parseFluorescence(null)).toBeNull();
+ });
+
+ it('detects phosphorescence', () => {
+ const fl = parseFluorescence('LW: blue moderate; phosphoresces yellow');
+ expect(fl?.phosphorescence).toMatch(/phosphoresc/);
+ });
+});
+
+describe('scoreUvMatch', () => {
+ it('returns 0 for null fluorescence', () => {
+ expect(
+ scoreUvMatch(
+ { lwuvIntensity: 'strong', lwuvColor: 'red', swuvIntensity: 'inert', swuvColor: '' },
+ null,
+ ),
+ ).toBe(0);
+ });
+
+ it('scores high for matching colour and intensity', () => {
+ const fl = parseFluorescence('LW: red strong; SW: inert')!;
+ const score = scoreUvMatch(
+ { lwuvIntensity: 'strong', lwuvColor: 'red', swuvIntensity: 'inert', swuvColor: '' },
+ fl,
+ );
+ expect(score).toBeGreaterThan(0.8);
+ });
+
+ it('scores low for mismatched colour', () => {
+ const fl = parseFluorescence('LW: red strong; SW: inert')!;
+ const score = scoreUvMatch(
+ { lwuvIntensity: 'strong', lwuvColor: 'green', swuvIntensity: 'inert', swuvColor: '' },
+ fl,
+ );
+ expect(score).toBeLessThanOrEqual(0.7);
+ });
+});
diff --git a/src/lib/uv-fluorescence/parse-fluorescence.ts b/src/lib/uv-fluorescence/parse-fluorescence.ts
new file mode 100644
index 0000000..ac58379
--- /dev/null
+++ b/src/lib/uv-fluorescence/parse-fluorescence.ts
@@ -0,0 +1,140 @@
+/**
+ * UV-fluorescence parser.
+ *
+ * The mineral database stores fluorescence as a freeform text field
+ * ("LWUV: red strong; SWUV: weak red; phosphoresces"). To support a UV
+ * lookup widget without a SQL schema migration, we parse that string into
+ * structured `{lwuv, swuv, phosphorescence}` triples, then match against
+ * user-reported observations.
+ *
+ * The parser is forgiving — it handles many trade abbreviations (LW/SW,
+ * 365 nm/254 nm, "long-wave", "short-wave") and conservatively returns
+ * `undefined` per field when uncertain.
+ */
+
+export type UvIntensity = 'inert' | 'weak' | 'moderate' | 'strong' | 'very_strong' | 'unknown';
+
+export interface UvFluorescence {
+ lwuv?: { color?: string; intensity: UvIntensity };
+ swuv?: { color?: string; intensity: UvIntensity };
+ phosphorescence?: string;
+}
+
+const INTENSITY_RE = /\b(inert|none|weak|moderate|medium|strong|very[\s-]?strong|intense)\b/i;
+
+const COLOR_RE =
+ /\b(red(?:dish)?|orange|yellow(?:ish)?|green(?:ish)?|blue(?:ish)?|violet|purple|pink|white|chalky|cream|salmon|brown(?:ish)?)\b/i;
+
+function intensityFrom(s: string): UvIntensity {
+ const m = s.match(INTENSITY_RE);
+ if (!m) return 'unknown';
+ const w = m[1].toLowerCase().replace(/[\s-]/g, '');
+ if (w === 'inert' || w === 'none') return 'inert';
+ if (w === 'weak') return 'weak';
+ if (w === 'moderate' || w === 'medium') return 'moderate';
+ if (w === 'verystrong' || w === 'intense') return 'very_strong';
+ if (w === 'strong') return 'strong';
+ return 'unknown';
+}
+
+function colorFrom(s: string): string | undefined {
+ const m = s.match(COLOR_RE);
+ return m ? m[0].toLowerCase() : undefined;
+}
+
+/**
+ * Parse a freeform fluorescence text into structured fields.
+ * Examples handled:
+ * "LW: red strong; SW: inert"
+ * "Long-wave UV strong red, short-wave weak"
+ * "365 nm: red moderate; 254 nm: chalky white weak"
+ * "Inert"
+ */
+export function parseFluorescence(text: string | null | undefined): UvFluorescence | null {
+ if (!text || !text.trim()) return null;
+ const t = text.toLowerCase();
+
+ if (/\b(inert|none)\b/i.test(t) && !/\b(lw|sw|long|short|365|254)\b/i.test(t)) {
+ return { lwuv: { intensity: 'inert' }, swuv: { intensity: 'inert' } };
+ }
+
+ // Split on common separators that signal LW vs SW segments.
+ const segments = t
+ .split(/[;.\n]|(?:,| and )\s*(?=(?:lw|sw|long|short|365|254))/i)
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ let lwuv: UvFluorescence['lwuv'];
+ let swuv: UvFluorescence['swuv'];
+ let phosphorescence: string | undefined;
+
+ for (const seg of segments) {
+ if (/phosphoresc/.test(seg)) phosphorescence = seg.trim();
+
+ const isLW = /\b(lw|long[-\s]?wave|365)/.test(seg);
+ const isSW = /\b(sw|short[-\s]?wave|254)/.test(seg);
+ const intensity = intensityFrom(seg);
+ const color = colorFrom(seg);
+
+ if (isLW && intensity !== 'unknown') lwuv = { intensity, color };
+ if (isSW && intensity !== 'unknown') swuv = { intensity, color };
+ // Untagged segment with both intensity+color: assume LW (the trade default).
+ if (!isLW && !isSW && intensity !== 'unknown' && !lwuv) {
+ lwuv = { intensity, color };
+ }
+ }
+
+ if (!lwuv && !swuv && !phosphorescence) return null;
+ return { lwuv, swuv, phosphorescence };
+}
+
+export interface UvObservation {
+ lwuvIntensity: UvIntensity;
+ lwuvColor: string;
+ swuvIntensity: UvIntensity;
+ swuvColor: string;
+}
+
+/**
+ * Score how well a mineral's parsed fluorescence matches an observation.
+ * Returns 0..1; values below 0.25 are filtered out by callers.
+ */
+export function scoreUvMatch(obs: UvObservation, fl: UvFluorescence | null): number {
+ if (!fl) return 0;
+ let score = 0;
+ let weight = 0;
+
+ const cmpField = (
+ obsIntensity: UvIntensity,
+ obsColor: string,
+ field: { intensity: UvIntensity; color?: string } | undefined,
+ ) => {
+ if (obsIntensity === 'unknown') return;
+ weight += 1;
+ if (!field) return;
+ // Intensity similarity (rank distance).
+ const rank: Record = {
+ inert: 0,
+ weak: 1,
+ moderate: 2,
+ strong: 3,
+ very_strong: 4,
+ unknown: 2,
+ };
+ const intensityDiff = Math.abs(rank[obsIntensity] - rank[field.intensity]);
+ const intensityScore = Math.max(0, 1 - intensityDiff * 0.25);
+ let colorScore = 0.5;
+ if (obsColor && field.color) {
+ colorScore = obsColor.toLowerCase() === field.color.toLowerCase() ? 1 : 0;
+ } else if (!obsColor) {
+ colorScore = 1;
+ }
+ score += 0.4 * intensityScore + 0.6 * colorScore;
+ };
+
+ cmpField(obs.lwuvIntensity, obs.lwuvColor, fl.lwuv);
+ cmpField(obs.swuvIntensity, obs.swuvColor, fl.swuv);
+
+ if (weight === 0) return 0;
+ return score / weight;
+}