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 (
-
-
- Loading charts...
-
- );
- }
-
- const {
- LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
- ScatterChart, Scatter, ZAxis, Legend, BarChart, Bar, Cell, ComposedChart, Area, ReferenceLine,
- } = recharts;
-
- return (
-
-
-
-
-
-
Anomaly Detection Simulator
-
Seeded cold-chain + lot-flow simulator.
-
-
-
-
- } onClick={() => {
- if (reseedPendingRef.current) return;
- reseedPendingRef.current = true;
- trackToolRunStarted('fsma-unified');
- setSeed(s => (s + 1) % 10000);
- setSelectedHour(null);
- }}>Reseed
-
-
-
-
-
-
-
-
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})}
-
-
-
-
-
-
-
-
-
-
-
-
- - • 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