diff --git a/frontend/src/app/tools/drill-simulator/components/DrillSimulatorClient.tsx b/frontend/src/app/tools/drill-simulator/components/DrillSimulatorClient.tsx index 6a1467c6f..1b8987f29 100644 --- a/frontend/src/app/tools/drill-simulator/components/DrillSimulatorClient.tsx +++ b/frontend/src/app/tools/drill-simulator/components/DrillSimulatorClient.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'; import { Badge, type BadgeTone } from '@/components/ui/badge'; import { Callout } from '@/components/ui/callout'; import { - Play, CheckCircle2, AlertTriangle, FileText, + Play, CheckCircle2, AlertTriangle, ArrowRight, Clock, Loader2, Network, Upload, Download, ChevronDown, ChevronRight, Timer, Shield, Zap, TrendingUp, RotateCcw, @@ -25,7 +25,6 @@ import { trackToolRunStarted, trackToolRunCompleted } from '@/lib/conversion-eve type Phase = 'scenario' | 'upload' | 'analyzing' | 'results' | 'report'; interface DrillState { - selectedScenario: string | null; uploadedFile: File | null; validationResult: ValidationResult | null; phase: Phase; @@ -33,30 +32,6 @@ interface DrillState { uploadTime: number | null; } -const scenarios = [ - { - id: 'produce', - title: 'Fresh Produce Supplier', - description: 'Multi-state leafy greens distributor with 500+ retail customers', - complexity: 'High', - recordCount: '2,000+', - }, - { - id: 'dairy', - title: 'Dairy Processing Facility', - description: 'Fluid milk processor with complex ingredients and co-packers', - complexity: 'Medium', - recordCount: '800+', - }, - { - id: 'supplements', - title: 'Supplement Manufacturer', - description: 'Multi-ingredient dietary supplements with international suppliers', - complexity: 'Very High', - recordCount: '5,000+', - }, -]; - const analysisStages = [ { name: 'Parsing records...', duration: 500 }, { name: 'Mapping CTEs and KDEs...', duration: 800 }, @@ -67,7 +42,6 @@ const analysisStages = [ export function DrillSimulatorClient() { const [state, setState] = useState({ - selectedScenario: null, uploadedFile: null, validationResult: null, phase: 'scenario', @@ -104,10 +78,9 @@ export function DrillSimulatorClient() { return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; }; - const handleScenarioSelect = (scenarioId: string, drillStartTime: number) => { + const handleStartDrill = (drillStartTime: number) => { setState({ ...state, - selectedScenario: scenarioId, phase: 'upload', drillStartTime, uploadTime: null, @@ -231,14 +204,10 @@ export function DrillSimulatorClient() { if (!state.validationResult) return; const result = state.validationResult; - const scenarioName = - scenarios.find((s) => s.id === state.selectedScenario)?.title || - 'Drill'; const report = `FDA RECALL DRILL SIMULATOR: FULL REPORT Generated: ${new Date().toISOString()} -SCENARIO: ${scenarioName} RECORDS ANALYZED: ${result.totalRecords} OVERALL SCORE: ${result.scores.overallScore.toFixed(1)}/100 (${result.scores.grade}) @@ -297,7 +266,6 @@ STATUS: ${ const handleReset = () => { setState({ - selectedScenario: null, uploadedFile: null, validationResult: null, phase: 'scenario', @@ -341,33 +309,12 @@ STATUS: ${

-
- {scenarios.map((scenario) => ( - - ))} -
-
-
- -

- Scenario: {scenarios.find((s) => s.id === state.selectedScenario)?.title} -

-

- {scenarios.find((s) => s.id === state.selectedScenario)?.description} -

-
-
{gapSummary}

- Self-check on synthetic drill data. Not a compliance verdict. + Self-check on the records analyzed. Not a compliance verdict.

@@ -663,7 +601,7 @@ STATUS: ${ {gapSummary}

- Self-check on synthetic drill data. Not a compliance verdict. + Self-check on the records analyzed. Not a compliance verdict.

@@ -892,7 +830,7 @@ STATUS: ${ headline="Get Your Drill Findings" subheadline="Unlock detailed findings, gap analysis, and a downloadable self-check report." ctaText="Unlock Findings" - toolContext={{ toolInputs: { score: result.scores.overallScore, grade: result.scores.grade, scenario: state.selectedScenario } }} + toolContext={{ toolInputs: { score: result.scores.overallScore, grade: result.scores.grade } }} teaser={teaser} onUnlock={() => {}} > diff --git a/frontend/src/app/tools/fsma-unified/components/AnomalySimulator.tsx b/frontend/src/app/tools/fsma-unified/components/AnomalySimulator.tsx deleted file mode 100644 index 7bea17323..000000000 --- a/frontend/src/app/tools/fsma-unified/components/AnomalySimulator.tsx +++ /dev/null @@ -1,392 +0,0 @@ -'use client'; - -import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Card, CardContent } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { EmptyState } from "@/components/ui/empty-state"; -import { AlertTriangle, Loader2, RefreshCw, Sparkles } from "lucide-react"; -import { trackToolRunStarted, trackToolRunCompleted } from "@/lib/conversion-events"; - -// ── Lazy-loaded recharts ── -type RechartsModule = typeof import("recharts"); - -function useRecharts() { - const [mod, setMod] = useState(null); - useEffect(() => { - let cancelled = false; - import("recharts").then(m => { if (!cancelled) setMod(m); }); - return () => { cancelled = true; }; - }, []); - return mod; -} - -// ── Types ── -type RNG = () => number; -function mulberry32(seed: number): RNG { - let a = seed >>> 0; - return function () { - a |= 0; a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} -function clamp(n: number, min: number, max: number) { return Math.max(min, Math.min(max, n)); } -function mean(arr: number[]) { return arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; } -function stddev(arr: number[]) { - if (arr.length < 2) return 0; - const m = mean(arr); - return Math.sqrt(arr.reduce((a, b) => a + (b - m) ** 2, 0) / (arr.length - 1)); -} -function fmt(n: number, d = 2) { return Number.isFinite(n) ? n.toFixed(d) : "N/A"; } -function isoHourLabel(ts: number) { return new Date(ts).toISOString().slice(0, 13) + ":00"; } - -type TempPoint = { t: number; hour: string; tempF: number; injectedAnomaly: boolean; dayIdx: number; hourOfDay: number }; -type LotFlowPoint = { id: string; supplierId: string; supplierName: string; lot: string; dayIdx: number; qty: number; tempExposureHours: number; transitHours: number; tempOnArrival: number; flagged: boolean }; -type Supplier = { id: string; name: string; region: string }; -type AlgoMode = "ensemble" | "statistical" | "rule" | "pattern"; -type DetectionResult = { flaggedHours: Set; explanations: Map }; -type Confusion = { tp: number; fp: number; tn: number; fn: number; precision: number; recall: number; f1: number }; -type DailyAggregate = { day: number; label: string; min: number; max: number; avg: number; anomalyCount: number; maxScore: number }; - -// ── Synthetic Data Generator ── -function generateSyntheticColdChain(seed = 204, days = 90) { - const rng = mulberry32(seed); - const start = Date.UTC(2026, 0, 1); - const totalHours = days * 24; - const anomalyDays = new Set(); - while (anomalyDays.size < 6) anomalyDays.add(Math.floor(rng() * days)); - - const points: TempPoint[] = []; - for (let h = 0; h < totalHours; h++) { - const t = start + h * 3600_000; - const dayIdx = Math.floor(h / 24); - const hourOfDay = h % 24; - const diurnal = Math.sin((2 * Math.PI * hourOfDay) / 24) * 0.7; - const noise = (rng() - 0.5) * 0.9; - let tempF = 36.5 + diurnal + noise; - let injected = false; - if (anomalyDays.has(dayIdx)) { - const startExc = 10 + Math.floor(rng() * 6); - const dur = 5 + Math.floor(rng() * 3); - if (hourOfDay >= startExc && hourOfDay < startExc + dur) { tempF = 42 + (rng() - 0.5) * 1.2; injected = true; } - } - points.push({ t, hour: isoHourLabel(t), tempF: Number(tempF.toFixed(2)), injectedAnomaly: injected, dayIdx, hourOfDay }); - } - - const suppliers: Supplier[] = [ - { id: "sup-a", name: "Arroyo Farms", region: "CA" }, { id: "sup-b", name: "Delta Leaf Co.", region: "AZ" }, - { id: "sup-c", name: "North Basin Produce", region: "NV" }, { id: "sup-d", name: "Canyon Ridge Growers", region: "CA" }, - { id: "sup-e", name: "Mesa Fresh Logistics", region: "AZ" }, { id: "sup-f", name: "Pacific Valley Supply", region: "CA" }, - ]; - - const lots: LotFlowPoint[] = []; - for (let i = 0; i < 200; i++) { - const s = suppliers[Math.floor(rng() * suppliers.length)]; - const dayIdx = Math.floor(rng() * days); - const qty = Math.round(80 + rng() * 920); - const exposure = anomalyDays.has(dayIdx) ? 2 + Math.floor(rng() * 6) : Math.floor(rng() * 2); - const transitHours = 2 + Math.floor(rng() * 48); - const isAnomalous = anomalyDays.has(dayIdx) || (transitHours > 36 && rng() > 0.5); - const tempOnArrival = isAnomalous ? 38 + Math.floor(rng() * 20) : 34 + Math.floor(rng() * 4); - lots.push({ id: `lf-${i}`, supplierId: s.id, supplierName: s.name, lot: `RML-${String(dayIdx).padStart(3, "0")}-${Math.floor(100 + rng() * 900)}`, dayIdx, qty, tempExposureHours: exposure, transitHours, tempOnArrival, flagged: false }); - } - return { points, lots, suppliers, anomalyDays, start, days }; -} - -// ── Detection Algorithms ── -function detectAnomalies(series: TempPoint[], mode: AlgoMode, s01: number): DetectionResult { - const temps = series.map(p => p.tempF); - const m = mean(temps), sd = stddev(temps); - const zThresh = 3.0 - s01 * 1.2; - const consHours = Math.round(4 - s01 * 2); - const driftThresh = 1.6 - s01 * 0.8; - const explain = new Map(); - - const statFlags = new Set(); - if (sd > 0) series.forEach(p => { const z = Math.abs((p.tempF - m) / sd); if (z >= zThresh) { statFlags.add(p.hour); explain.set(p.hour, `Statistical (Z-score): |(${fmt(p.tempF)} − ${fmt(m)}) / ${fmt(sd)}| = ${fmt(z)} ≥ ${fmt(zThresh)}.`); } }); - - const ruleFlags = new Set(); - let run = 0; - for (let i = 0; i < series.length; i++) { - if (series[i].tempF > 40) run++; else run = 0; - if (run >= consHours) for (let k = i - (consHours - 1); k <= i; k++) { ruleFlags.add(series[k].hour); if (!explain.has(series[k].hour)) explain.set(series[k].hour, `Rule-based: ${fmt(series[k].tempF)}°F > 40°F for ≥ ${consHours} consecutive hours.`); } - } - - const patternFlags = new Set(); - for (let i = 192; i < series.length; i++) { - const mw = mean(series.slice(i - 24, i).map(p => p.tempF)); - const mb = mean(series.slice(i - 192, i - 24).map(p => p.tempF)); - if (mw - mb >= driftThresh) { patternFlags.add(series[i - 1].hour); if (!explain.has(series[i - 1].hour)) explain.set(series[i - 1].hour, `Pattern drift: 24h mean (${fmt(mw)}°F) − 7d baseline (${fmt(mb)}°F) = ${fmt(mw - mb)}°F ≥ ${fmt(driftThresh)}°F.`); } - } - - const combine = (a: Set, b: Set) => { const o = new Set(a); b.forEach(x => o.add(x)); return o; }; - let out: Set; - if (mode === "statistical") out = statFlags; - else if (mode === "rule") out = ruleFlags; - else if (mode === "pattern") out = patternFlags; - else out = combine(combine(statFlags, ruleFlags), patternFlags); - - out.forEach(h => { const p = series.find(x => x.hour === h); if (p && p.tempF > 40) { const prior = explain.get(h) || ""; explain.set(h, `${prior} Regulatory: cold-chain deviations are a hazard-control concern under 21 CFR Part 117.`); } }); - return { flaggedHours: out, explanations: explain }; -} - -function confusionMatrix(series: TempPoint[], flagged: Set): Confusion { - let tp = 0, fp = 0, tn = 0, fn = 0; - for (const p of series) { const pred = flagged.has(p.hour); if (pred && p.injectedAnomaly) tp++; else if (pred) fp++; else if (p.injectedAnomaly) fn++; else tn++; } - const precision = tp + fp === 0 ? 0 : tp / (tp + fp); - const recall = tp + fn === 0 ? 0 : tp / (tp + fn); - const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall); - return { tp, fp, tn, fn, precision, recall, f1 }; -} - -function costRec(c: Confusion, fpC: number, fnC: number) { - const eFp = c.fp * fpC, eFn = c.fn * fnC; - if (eFn > eFp * 2) return { headline: "Bias toward sensitivity", detail: "False negatives dominate cost. Increase sensitivity." }; - if (eFp > eFn * 2) return { headline: "Bias toward specificity", detail: "False positives dominate cost. Reduce sensitivity." }; - return { headline: "Balanced tradeoff", detail: "Costs in same band. Focus on data quality." }; -} - -function aggregateDaily(points: TempPoint[], flagged: Set): DailyAggregate[] { - const days: Record = {}; - points.forEach(p => { if (!days[p.dayIdx]) days[p.dayIdx] = { temps: [], anomalies: 0 }; days[p.dayIdx].temps.push(p.tempF); if (flagged.has(p.hour)) days[p.dayIdx].anomalies++; }); - return Object.entries(days).map(([d, v]) => ({ day: parseInt(d), label: `Day ${parseInt(d) + 1}`, min: Math.round(Math.min(...v.temps) * 10) / 10, max: Math.round(Math.max(...v.temps) * 10) / 10, avg: Math.round(mean(v.temps) * 10) / 10, anomalyCount: v.anomalies, maxScore: v.anomalies > 0 ? Math.min(1, v.anomalies / 8) : 0 })); -} - -export function AnomalyDetectionSimulator() { - const recharts = useRecharts(); - const [seed, setSeed] = useState(204); - const [mode, setMode] = useState("ensemble"); - const [sensitivity, setSensitivity] = useState(55); - const [tab, setTab] = useState<"stream" | "daily" | "lots" | "suppliers" | "eval">("stream"); - const [selectedHour, setSelectedHour] = useState(null); - const [fpCost, setFpCost] = useState(800); - const [fnCost, setFnCost] = useState(50000); - const reseedPendingRef = useRef(false); - - const dataset = useMemo(() => generateSyntheticColdChain(seed, 90), [seed]); - useEffect(() => { - if (reseedPendingRef.current) { - reseedPendingRef.current = false; - trackToolRunCompleted('fsma-unified', 'success'); - } - }, [dataset]); - const det = useMemo(() => detectAnomalies(dataset.points, mode, sensitivity / 100), [dataset.points, mode, sensitivity]); - const conf = useMemo(() => confusionMatrix(dataset.points, det.flaggedHours), [dataset.points, det.flaggedHours]); - const rec = useMemo(() => costRec(conf, fpCost, fnCost), [conf, fpCost, fnCost]); - const dailyData = useMemo(() => aggregateDaily(dataset.points, det.flaggedHours), [dataset.points, det.flaggedHours]); - - const streamData = useMemo(() => dataset.points.map((p, idx) => ({ idx, hour: p.hour, tempF: p.tempF, flagged: det.flaggedHours.has(p.hour) ? 1 : 0 })), [dataset.points, det.flaggedHours]); - - const lotsData = useMemo(() => { - const dayHasFlag = new Set(); - dataset.points.forEach((p, i) => { if (det.flaggedHours.has(p.hour)) dayHasFlag.add(Math.floor(i / 24)); }); - return dataset.lots.map(l => ({ ...l, supplier: l.supplierName, flagged: dayHasFlag.has(l.dayIdx) || l.tempExposureHours >= 4 })); - }, [dataset, det.flaggedHours]); - - const normalLots = useMemo(() => lotsData.filter(l => !l.flagged), [lotsData]); - const flaggedLots = useMemo(() => lotsData.filter(l => l.flagged), [lotsData]); - - const supplierMatrix = useMemo(() => { - const m = new Map(); - dataset.suppliers.forEach(s => m.set(s.id, { name: s.name, region: s.region, lots: 0, flagged: 0, exp: 0, transit: 0, temp: 0 })); - lotsData.forEach(l => { const e = m.get(l.supplierId); if (!e) return; e.lots++; if (l.flagged) e.flagged++; e.exp += l.tempExposureHours; e.transit += l.transitHours; e.temp += l.tempOnArrival; }); - return Array.from(m.entries()).map(([id, v]) => { - const rate = v.lots ? v.flagged / v.lots : 0; - return { id, supplier: v.name, region: v.region, lots: v.lots, flaggedLots: v.flagged, flaggedRate: Number((rate * 100).toFixed(1)), avgTransit: Number((v.lots ? v.transit / v.lots : 0).toFixed(1)), avgTemp: Number((v.lots ? v.temp / v.lots : 0).toFixed(1)), score: Number(clamp(rate * 70 + ((v.lots ? v.exp / v.lots : 0) / 6) * 30, 0, 100).toFixed(1)) }; - }).sort((a, b) => b.score - a.score); - }, [dataset.suppliers, lotsData]); - - const topFlags = useMemo(() => dataset.points.filter(p => det.flaggedHours.has(p.hour)).slice(-12).reverse(), [dataset.points, det.flaggedHours]); - // Chart marks read the token layer so they follow the theme (SPEC §1.6). - const sevFill = (s: number) => (s > 0.4 ? "var(--re-danger)" : s > 0.1 ? "var(--re-warning)" : "var(--re-success)"); - const sevOpacity = (s: number) => (s > 0.7 ? 1 : s > 0.4 ? 0.8 : s > 0.1 ? 0.55 : 0.25); - - if (!recharts) { - return ( -
-
- ); - } - - const { - LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, - ScatterChart, Scatter, ZAxis, Legend, BarChart, Bar, Cell, ComposedChart, Area, ReferenceLine, - } = recharts; - - return ( -
-
-
-
-
- - -
-
- - -
-
- -
Higher = more flags.
-
- setSensitivity(Number(e.target.value))} - min={0} max={100} step={1} - /> -
{sensitivity}/100
-
-
-
-
-
Precision
{fmt(conf.precision * 100, 1)}%
-
Recall
{fmt(conf.recall * 100, 1)}%
-
F1
{fmt(conf.f1 * 100, 1)}%
-
-
-
- -
-
- setTab(v as "stream" | "daily" | "lots" | "suppliers" | "eval")}> - - Temp stream - Daily overview - Lot scatter - Supplier risk - Evaluation - - - -
- - - n === "tempF" ? [`${fmt(Number(v ?? 0))} °F`, "Temp"] : [String(v ?? ""), String(n)]} labelFormatter={(l) => streamData[Number(l)]?.hour || String(l)} /> - - -
-
-
Recent flags
- {topFlags.length === 0 ? : topFlags.map(p => ( - - ))} -
-
Explainability
- {selectedHour ?
{selectedHour}
{det.explanations.get(selectedHour) || "No explanation."}
:
Click a flag to view explanation.
} -
-
-
- - -
- - - - - - ) => { const { cx, cy, payload } = props as { cx?: number; cy?: number; payload?: { anomalyCount?: number; day?: number } }; return (payload?.anomalyCount ?? 0) > 0 ? : ; }} name="Max" /> - - -
-
- - {dailyData.map((d, i) => )} - -
-
- - -
- - - - - - }> }) => { if (!payload?.[0]) return null; const d = payload[0].payload as { lot?: string; supplier?: string; transitHours?: number; tempOnArrival?: number }; return
{d.lot}
{d.supplier}
Transit: {d.transitHours}h · {d.tempOnArrival}°F
; }) as never} /> - -
-
-
- - -
-
- - {supplierMatrix.map((s, i) => 40 ? "var(--re-danger)" : s.score > 20 ? "var(--re-warning)" : "var(--re-success)"} />)} - -
-
- SupplierFlag%Score - {supplierMatrix.map(r =>
{r.supplier}
{r.region} · {r.lots} lots · {r.avgTemp}°F avg
{r.flaggedRate}%{r.score}
)}
-
-
-
- - -
-
-
True positives
{conf.tp}
-
False positives
{conf.fp}
-
False negatives
{conf.fn}
-
True negatives
{conf.tn}
-
-
-
-
-
-
-
-
    -
  • • Multi-algorithm detection
  • • Evaluation harness
  • • Explainability per event
  • • Cost-based tuning lens
  • • Daily aggregation
  • -
-
-
-
-
- ); -} diff --git a/frontend/src/app/tools/fsma-unified/components/UnifiedDashboardClient.tsx b/frontend/src/app/tools/fsma-unified/components/UnifiedDashboardClient.tsx index 6706c07dd..3d216680d 100644 --- a/frontend/src/app/tools/fsma-unified/components/UnifiedDashboardClient.tsx +++ b/frontend/src/app/tools/fsma-unified/components/UnifiedDashboardClient.tsx @@ -1,50 +1,32 @@ 'use client'; import React from "react"; -import { motion } from "framer-motion"; -import dynamic from "next/dynamic"; - -const AnomalyDetectionSimulator = dynamic( - () => import("./AnomalySimulator").then(mod => ({ default: mod.AnomalyDetectionSimulator })), - { - ssr: false, - loading: () => ( -
- Loading simulator... -
- ), - } -); +import Link from "next/link"; +import { Thermometer } from "lucide-react"; import { FreeToolPageShell } from "@/components/layout/FreeToolPageShell"; -import { LeadGate } from "@/components/lead-gate/LeadGate"; +import { EmptyState } from "@/components/ui/empty-state"; +import { Button } from "@/components/ui/button"; export function UnifiedDashboardClient() { return ( - -
🌡️
-

Cold Chain Anomaly Detection

-

Ensemble, statistical, rule-based, and pattern-drift algorithms with a full evaluation harness, lot scatter, and supplier risk matrix.

-
+ + + Open the Data Import Hub + + } - > - - - - + /> ); } diff --git a/frontend/src/app/tools/knowledge-graph/components/KnowledgeGraph.tsx b/frontend/src/app/tools/knowledge-graph/components/KnowledgeGraph.tsx index 9df292e65..f67335c1c 100644 --- a/frontend/src/app/tools/knowledge-graph/components/KnowledgeGraph.tsx +++ b/frontend/src/app/tools/knowledge-graph/components/KnowledgeGraph.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useEffect, useMemo, useRef, useState } from "react"; +import Link from "next/link"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -15,15 +16,6 @@ import { import { GitBranch, Search } from "lucide-react"; // ── Utilities ── -function mulberry32(seed: number) { - let a = seed >>> 0; - return function () { - a |= 0; a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} function clamp(n: number, min: number, max: number) { return Math.max(min, Math.min(max, n)); } // ── Types ── @@ -63,36 +55,6 @@ function hexToRgba(hex: string, a: number) { function nodeR(t: NodeType) { return t === "Facility" ? 20 : t === "Supplier" || t === "Product" ? 18 : t === "Location" ? 14 : 16; } -function seededScenario(seed = 204) { - const rng = mulberry32(seed); const j = () => (rng() - 0.5) * 30; - const nodes: GraphNode[] = [ - { id: "sup-1", type: "Supplier", label: "Arroyo Farms", x: 120 + j(), y: 140 + j(), meta: { region: "CA" } }, - { id: "loc-1", type: "Location", label: "Salinas, CA", x: 110 + j(), y: 260 + j() }, - { id: "fac-1", type: "Facility", label: "Cooler • Farm Pack", x: 280 + j(), y: 160 + j() }, - { id: "cte-1", type: "CTE", label: "Harvest / Cooling", x: 280 + j(), y: 260 + j() }, - { id: "prod-1", type: "Product", label: "Romaine Lettuce", x: 460 + j(), y: 140 + j() }, - { id: "lot-1", type: "Lot", label: "TLC: RML-021-744", x: 460 + j(), y: 260 + j() }, - { id: "fac-2", type: "Facility", label: "Processor • Wash/Trim", x: 640 + j(), y: 150 + j() }, - { id: "cte-2", type: "CTE", label: "Transform", x: 640 + j(), y: 260 + j() }, - { id: "lot-2", type: "Lot", label: "TLC: RML-021-744-A", x: 800 + j(), y: 260 + j() }, - { id: "fac-3", type: "Facility", label: "DC • West", x: 960 + j(), y: 150 + j() }, - { id: "cte-3", type: "CTE", label: "Ship to DC", x: 960 + j(), y: 260 + j() }, - { id: "fac-4", type: "Facility", label: "Retail • Pasadena", x: 1120 + j(), y: 150 + j() }, - { id: "cte-4", type: "CTE", label: "Receive / Sell", x: 1120 + j(), y: 260 + j() }, - ]; - const edges: GraphEdge[] = [ - { id: "e-1", type: "LocatedAt", from: "sup-1", to: "loc-1" }, { id: "e-2", type: "Supplies", from: "sup-1", to: "fac-1" }, - { id: "e-3", type: "TracedFrom", from: "cte-1", to: "fac-1" }, { id: "e-4", type: "Contains", from: "prod-1", to: "lot-1" }, - { id: "e-5", type: "TracedFrom", from: "cte-1", to: "lot-1" }, { id: "e-6", type: "ShipsTo", from: "fac-1", to: "fac-2" }, - { id: "e-7", type: "TracedFrom", from: "cte-2", to: "fac-2" }, { id: "e-8", type: "Transforms", from: "lot-1", to: "lot-2" }, - { id: "e-9", type: "TracedFrom", from: "cte-2", to: "lot-2" }, { id: "e-10", type: "ShipsTo", from: "fac-2", to: "fac-3" }, - { id: "e-11", type: "TracedFrom", from: "cte-3", to: "fac-3" }, { id: "e-12", type: "ShipsTo", from: "fac-3", to: "fac-4" }, - { id: "e-13", type: "TracedFrom", from: "cte-4", to: "fac-4" }, { id: "e-14", type: "TracedFrom", from: "cte-3", to: "lot-2" }, - { id: "e-15", type: "TracedFrom", from: "cte-4", to: "lot-2" }, - ]; - return { nodes, edges }; -} - function traceNodes(startId: string, edges: GraphEdge[], mode: TraceMode, depth: number) { const out = new Map(), inn = new Map(); edges.forEach(e => { if (!out.has(e.from)) out.set(e.from, []); if (!inn.has(e.to)) inn.set(e.to, []); out.get(e.from)!.push(e.to); inn.get(e.to)!.push(e.from); }); @@ -115,11 +77,8 @@ function useAnimFrame(cb: () => void, on: boolean) { } export function SupplyChainKnowledgeGraphBuilder() { - const [seed] = useState(204); - const scenario = useMemo(() => seededScenario(seed), [seed]); - const [nodes, setNodes] = useState(scenario.nodes); - const [edges, setEdges] = useState(scenario.edges); - useEffect(() => { setNodes(scenario.nodes); setEdges(scenario.edges); }, [scenario]); + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); const canvasRef = useRef(null); const containerRef = useRef(null); @@ -247,6 +206,30 @@ export function SupplyChainKnowledgeGraphBuilder() { const onDown = (e: React.MouseEvent) => { const w = toW(e.clientX, e.clientY); const h = hitNode(w.x, w.y); if (h) { setSelEdge(null); setSelNode(h); setDragId(h); const n = nodes.find(x => x.id === h); if (n) setDragOff({ x: w.x - n.x, y: w.y - n.y }); } else { setSelNode(null); setSelEdge(null); setPanning(true); setLastPan({ x: e.clientX, y: e.clientY }); } }; const onMove = (e: React.MouseEvent) => { if (dragId && dragOff) { const w = toW(e.clientX, e.clientY); setNodes(p => p.map(n => n.id === dragId ? { ...n, x: w.x - dragOff.x, y: w.y - dragOff.y } : n)); } else if (panning && lastPan) { setVp(v => ({ ...v, panX: v.panX + e.clientX - lastPan.x, panY: v.panY + e.clientY - lastPan.y })); setLastPan({ x: e.clientX, y: e.clientY }); } }; const onUp = () => { setDragId(null); setDragOff(null); setPanning(false); setLastPan(null); }; + + if (nodes.length === 0) { + return ( +
+
+
Knowledge Graph Builder
+
FSMA 204-native traceability graph.
+
+ + + Import traceability data + + + } + /> +
+ ); + } + return (
diff --git a/frontend/src/app/tools/knowledge-graph/components/__tests__/KnowledgeGraph.test.ts b/frontend/src/app/tools/knowledge-graph/components/__tests__/KnowledgeGraph.test.ts index 086ca3437..3427811ed 100644 --- a/frontend/src/app/tools/knowledge-graph/components/__tests__/KnowledgeGraph.test.ts +++ b/frontend/src/app/tools/knowledge-graph/components/__tests__/KnowledgeGraph.test.ts @@ -1,57 +1,26 @@ import { describe, it, expect } from 'vitest'; - -// We map out the internal functional logic from the KnowledgeGraph component -// For testing purposes without exporting them directly from the TSX file, -// we duplicate the pure functions here to ensure the logic holds up to our standards. - -// ── Types ── -type NodeType = "Supplier" | "Facility" | "Product" | "Lot" | "CTE" | "Location"; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The graph component must ship with no hardcoded supply-chain records and +// must show an empty state when no data is loaded. These tests assert that +// invariant directly against the component source, plus keep coverage of the +// pure trace logic with an abstract structure (no invented records). + +const componentPath = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'KnowledgeGraph.tsx' +); +const source = readFileSync(componentPath, 'utf8'); + +// ── Types (mirrored from KnowledgeGraph.tsx) ── type EdgeType = "Supplies" | "Contains" | "Transforms" | "ShipsTo" | "TracedFrom" | "LocatedAt"; -type GraphNode = { id: string; type: NodeType; label: string; meta?: Record; x: number; y: number }; type GraphEdge = { id: string; type: EdgeType; from: string; to: string; meta?: Record }; type TraceMode = "forward" | "backward" | "bidirectional"; -// ── Pure Functions from KnowledgeGraph.tsx ── -function mulberry32(seed: number) { - let a = seed >>> 0; - return function () { - a |= 0; a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -function seededScenario(seed = 204) { - const rng = mulberry32(seed); const j = () => (rng() - 0.5) * 30; - const nodes: GraphNode[] = [ - { id: "sup-1", type: "Supplier", label: "Arroyo Farms", x: 120 + j(), y: 140 + j(), meta: { region: "CA" } }, - { id: "loc-1", type: "Location", label: "Salinas, CA", x: 110 + j(), y: 260 + j() }, - { id: "fac-1", type: "Facility", label: "Cooler • Farm Pack", x: 280 + j(), y: 160 + j() }, - { id: "cte-1", type: "CTE", label: "Harvest / Cooling", x: 280 + j(), y: 260 + j() }, - { id: "prod-1", type: "Product", label: "Romaine Lettuce", x: 460 + j(), y: 140 + j() }, - { id: "lot-1", type: "Lot", label: "TLC: RML-021-744", x: 460 + j(), y: 260 + j() }, - { id: "fac-2", type: "Facility", label: "Processor • Wash/Trim", x: 640 + j(), y: 150 + j() }, - { id: "cte-2", type: "CTE", label: "Transform", x: 640 + j(), y: 260 + j() }, - { id: "lot-2", type: "Lot", label: "TLC: RML-021-744-A", x: 800 + j(), y: 260 + j() }, - { id: "fac-3", type: "Facility", label: "DC • West", x: 960 + j(), y: 150 + j() }, - { id: "cte-3", type: "CTE", label: "Ship to DC", x: 960 + j(), y: 260 + j() }, - { id: "fac-4", type: "Facility", label: "Retail • Pasadena", x: 1120 + j(), y: 150 + j() }, - { id: "cte-4", type: "CTE", label: "Receive / Sell", x: 1120 + j(), y: 260 + j() }, - ]; - const edges: GraphEdge[] = [ - { id: "e-1", type: "LocatedAt", from: "sup-1", to: "loc-1" }, { id: "e-2", type: "Supplies", from: "sup-1", to: "fac-1" }, - { id: "e-3", type: "TracedFrom", from: "cte-1", to: "fac-1" }, { id: "e-4", type: "Contains", from: "prod-1", to: "lot-1" }, - { id: "e-5", type: "TracedFrom", from: "cte-1", to: "lot-1" }, { id: "e-6", type: "ShipsTo", from: "fac-1", to: "fac-2" }, - { id: "e-7", type: "TracedFrom", from: "cte-2", to: "fac-2" }, { id: "e-8", type: "Transforms", from: "lot-1", to: "lot-2" }, - { id: "e-9", type: "TracedFrom", from: "cte-2", to: "lot-2" }, { id: "e-10", type: "ShipsTo", from: "fac-2", to: "fac-3" }, - { id: "e-11", type: "TracedFrom", from: "cte-3", to: "fac-3" }, { id: "e-12", type: "ShipsTo", from: "fac-3", to: "fac-4" }, - { id: "e-13", type: "TracedFrom", from: "cte-4", to: "fac-4" }, { id: "e-14", type: "TracedFrom", from: "cte-3", to: "lot-2" }, - { id: "e-15", type: "TracedFrom", from: "cte-4", to: "lot-2" }, - ]; - return { nodes, edges }; -} - +// ── Pure function mirrored from KnowledgeGraph.tsx ── function traceNodes(startId: string, edges: GraphEdge[], mode: TraceMode, depth: number) { const out = new Map(), inn = new Map(); edges.forEach(e => { if (!out.has(e.from)) out.set(e.from, []); if (!inn.has(e.to)) inn.set(e.to, []); out.get(e.from)!.push(e.to); inn.get(e.to)!.push(e.from); }); @@ -68,81 +37,63 @@ function traceNodes(startId: string, edges: GraphEdge[], mode: TraceMode, depth: return { nodes: visited, edges: usedEdges }; } -describe('Supply Chain Knowledge Graph Logistics', () => { - - describe('seededScenario determinism', () => { - it('should generate identical graphs for the same seed', () => { - const graphA = seededScenario(204); - const graphB = seededScenario(204); - expect(graphA.nodes[0].x).toBeCloseTo(graphB.nodes[0].x); - expect(graphA.nodes[0].y).toBeCloseTo(graphB.nodes[0].y); - expect(graphA.edges.length).toBe(graphB.edges.length); - }); - - it('should generate different spatial layouts for different seeds', () => { - const graphA = seededScenario(204); - const graphB = seededScenario(999); - expect(graphA.nodes[0].x).not.toBe(graphB.nodes[0].x); - }); +describe('KnowledgeGraph data policy', () => { + it('ships no hardcoded graph scenario or seeded generator', () => { + expect(source).not.toContain('seededScenario'); + expect(source).not.toContain('mulberry32'); }); - describe('traceNodes BFS pathfinding', () => { - const { nodes, edges } = seededScenario(); - - it('should trace FORWARD downstream accurately', () => { - // Tracing forward from Supplier (sup-1) should hit Cooler (fac-1) at depth 1 - const trace = traceNodes('sup-1', edges, 'forward', 1); - expect(trace.nodes.has('sup-1')).toBe(true); - expect(trace.nodes.has('fac-1')).toBe(true); - expect(trace.nodes.has('loc-1')).toBe(true); - - // Should not hit depth 2 yet - expect(trace.nodes.has('fac-2')).toBe(false); + it('contains no invented supply-chain records (companies, lots, locations)', () => { + expect(source).not.toMatch(/Arroyo|Salinas|Pasadena|RML-\d|TLC:/); + }); - // Edges e-1 and e-2 should be used - expect(trace.edges.has('e-1')).toBe(true); - expect(trace.edges.has('e-2')).toBe(true); - }); + it('initializes with empty nodes and edges', () => { + expect(source).toContain('useState([])'); + expect(source).toContain('useState([])'); + }); - it('should trace BACKWARD upstream accurately', () => { - // Trace backward from DC (fac-3) should hit Processor (fac-2) at depth 1 - const trace = traceNodes('fac-3', edges, 'backward', 1); - expect(trace.nodes.has('fac-3')).toBe(true); - expect(trace.nodes.has('fac-2')).toBe(true); - expect(trace.nodes.has('cte-3')).toBe(true); + it('renders an empty state when no graph data is loaded', () => { + expect(source).toContain('EmptyState'); + expect(source).toContain('nodes.length === 0'); + }); +}); - // Should not see forward edges - expect(trace.nodes.has('fac-4')).toBe(false); - }); +describe('traceNodes BFS pathfinding', () => { + // Abstract structural graph: identifiers only, no records. + const edges: GraphEdge[] = [ + { id: 'e1', type: 'ShipsTo', from: 'a', to: 'b' }, + { id: 'e2', type: 'ShipsTo', from: 'b', to: 'c' }, + ]; - it('should trace BIDIRECTIONALLY across the full graph deeply', () => { - // Selecting a middle node like Processor (fac-2) and going depth 5 should capture almost everything - const trace = traceNodes('fac-2', edges, 'bidirectional', 5); - expect(trace.nodes.has('fac-2')).toBe(true); // Self - expect(trace.nodes.has('fac-1')).toBe(true); // Upstream Cooler - expect(trace.nodes.has('fac-4')).toBe(true); // Downstream Retail - expect(trace.nodes.has('sup-1')).toBe(true); // Original Supplier + it('traces forward across edges', () => { + const trace = traceNodes('a', edges, 'forward', 2); + expect(trace.nodes.has('a')).toBe(true); + expect(trace.nodes.has('b')).toBe(true); + expect(trace.nodes.has('c')).toBe(true); + expect(trace.edges.has('e1')).toBe(true); + expect(trace.edges.has('e2')).toBe(true); + }); - // Should identify significant edges - expect(trace.edges.has('e-6')).toBe(true); - expect(trace.edges.has('e-12')).toBe(true); - }); + it('traces backward across edges', () => { + const trace = traceNodes('c', edges, 'backward', 2); + expect(trace.nodes.has('a')).toBe(true); + expect(trace.nodes.has('b')).toBe(true); + expect(trace.nodes.has('c')).toBe(true); + }); - it('should strictly respect depth parameters', () => { - // Depth 0 should only contain the starting node - const trace0 = traceNodes('lot-1', edges, 'bidirectional', 0); - expect(trace0.nodes.size).toBe(1); - expect(trace0.edges.size).toBe(0); + it('respects depth limits', () => { + const trace0 = traceNodes('a', edges, 'bidirectional', 0); + expect(trace0.nodes.size).toBe(1); + expect(trace0.edges.size).toBe(0); - // Depth 1 from Lot 1 - const trace1 = traceNodes('lot-1', edges, 'bidirectional', 1); - expect(trace1.nodes.has('lot-1')).toBe(true); - expect(trace1.nodes.has('prod-1')).toBe(true); // From 'Contains' - expect(trace1.nodes.has('cte-1')).toBe(true); // From 'TracedFrom' - expect(trace1.nodes.has('lot-2')).toBe(true); // From 'Transforms' + const trace1 = traceNodes('a', edges, 'forward', 1); + expect(trace1.nodes.has('b')).toBe(true); + expect(trace1.nodes.has('c')).toBe(false); + }); - // Should NOT have deeply nested items - expect(trace1.nodes.has('fac-2')).toBe(false); // Depth 2 away from CTE-1/CTE-2 - }); + it('handles an empty graph', () => { + const trace = traceNodes('a', [], 'bidirectional', 3); + expect(trace.nodes.size).toBe(1); + expect(trace.edges.size).toBe(0); }); }); diff --git a/frontend/src/app/tools/sop-generator/components/SOPGeneratorClient.tsx b/frontend/src/app/tools/sop-generator/components/SOPGeneratorClient.tsx index 4f7e9fb8e..800c9deb9 100644 --- a/frontend/src/app/tools/sop-generator/components/SOPGeneratorClient.tsx +++ b/frontend/src/app/tools/sop-generator/components/SOPGeneratorClient.tsx @@ -145,7 +145,7 @@ ${retailers.length > 0 ? `## Retailer Requirements (informational)\n${retailers.
- +
@@ -161,11 +161,11 @@ ${retailers.length > 0 ? `## Retailer Requirements (informational)\n${retailers.
- +
- +
@@ -190,7 +190,6 @@ ${retailers.length > 0 ? `## Retailer Requirements (informational)\n${retailers. updated[i] = e.target.value; setProducts(updated); }} - placeholder="e.g. Roma Tomatoes, Romaine Lettuce" className="rounded-xl" /> {products.length > 1 && ( @@ -221,7 +220,6 @@ ${retailers.length > 0 ? `## Retailer Requirements (informational)\n${retailers. updated[i] = e.target.value; setFacilities(updated); }} - placeholder="e.g. Valley Fresh Farms, Salinas CA" className="rounded-xl" /> {facilities.length > 1 && ( diff --git a/frontend/src/app/why-regengine/page.tsx b/frontend/src/app/why-regengine/page.tsx index 7d6bd3587..f917c5c55 100644 --- a/frontend/src/app/why-regengine/page.tsx +++ b/frontend/src/app/why-regengine/page.tsx @@ -415,16 +415,16 @@ export default function WhyRegEnginePage() { Content-Type: application/json { - "traceability_lot_code": "LOT-2026-001", - "product": "Organic spinach", - "supplier_gln": "5012345678909", - "received_date": "2026-04-05", - "location_gln": "5012345678909" + "traceability_lot_code": "", + "product": "", + "supplier_gln": "<13-digit GLN>", + "received_date": "", + "location_gln": "<13-digit GLN>" } HTTP 201 { - "cte_id": "cte_abc123def456", + "cte_id": "", "status": "validated", "missing_kdes": [] }`} @@ -608,7 +608,7 @@ HTTP 201 {` - 2026-04-05T... + ObjectEvent