From a9d49990cf0c48b277e211c88ce67578df9e9228 Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:28:42 -0500 Subject: [PATCH 1/3] feat(math-engine): tilde-model regression with Levenberg-Marquardt and analytic Jacobians parseTildeModel splits Desmos-style 'y1 ~ a*exp(b*x1)' models (the tilde never reaches mathjs), extracts fit parameters vs regressors via extractVariables, and returns a discriminated union with explicit error codes. fitModel runs damped LM with per-parameter analytic Jacobians from the symbolic differentiator, optional box bounds (clamped + warned), smart initial seeds for the canned families (log-linear seeding for exponential, median-centered logistic, range-based sinusoid frequency), and an honest discriminated FitResult: failures carry invalid-model / insufficient-data / singular / diverged statuses, successes report converged vs max-iterations plus structured warnings (dropped-rows, bound-hit, zero-variance) so the UI can localize them. buildCannedModel emits the six prebaked model strings. 28 new tests: parse error paths, exact-string canned models, parameter recovery for all six families, agreement with linearRegression, residual / rmse / predict invariants, NaN-row dropping, insufficient-data, degenerate all-identical-x hygiene (never ok with non-finite params), bounds clamping, and a fast-check property test recovering arbitrary slope/intercept. Co-Authored-By: Claude Fable 5 --- packages/math-engine/src/stats/fit.test.ts | 275 +++++++++ packages/math-engine/src/stats/fit.ts | 636 +++++++++++++++++++++ packages/math-engine/src/stats/index.ts | 17 + 3 files changed, 928 insertions(+) create mode 100644 packages/math-engine/src/stats/fit.test.ts create mode 100644 packages/math-engine/src/stats/fit.ts diff --git a/packages/math-engine/src/stats/fit.test.ts b/packages/math-engine/src/stats/fit.test.ts new file mode 100644 index 00000000..6a9ff28f --- /dev/null +++ b/packages/math-engine/src/stats/fit.test.ts @@ -0,0 +1,275 @@ +/** + * Unit tests for arbitrary-model tilde regression (Levenberg-Marquardt) + */ + +import * as fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { buildCannedModel, fitModel, parseTildeModel } from './fit'; +import { linearRegression } from './regression'; + +/** Asserts a fit succeeded and narrows the type. */ +function expectOk( + result: ReturnType, +): Extract, { ok: true }> { + if (!result.ok) { + throw new Error(`Expected successful fit, got ${result.status}: ${result.message}`); + } + return result; +} + +describe('parseTildeModel', () => { + const columns = ['x1', 'y1']; + + it('parses a model with parameters and regressors', () => { + const parsed = parseTildeModel('y1 ~ a*exp(b*x1)', columns); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.dependent).toBe('y1'); + expect(parsed.parameters).toEqual(['a', 'b']); + expect(parsed.regressors).toEqual(['x1']); + expect(parsed.rhsText).toBe('a*exp(b*x1)'); + } + }); + + it('rejects input without a tilde', () => { + const parsed = parseTildeModel('y1 = a*x1', columns); + expect(parsed).toMatchObject({ ok: false, code: 'no-tilde' }); + }); + + it('rejects input with multiple tildes', () => { + const parsed = parseTildeModel('y1 ~ a ~ b', columns); + expect(parsed).toMatchObject({ ok: false, code: 'multiple-tildes' }); + }); + + it('rejects a dependent that is not a data column', () => { + const parsed = parseTildeModel('z ~ a*x1', columns); + expect(parsed).toMatchObject({ ok: false, code: 'invalid-dependent' }); + }); + + it('rejects a dependent that is an expression', () => { + const parsed = parseTildeModel('2*y1 ~ a*x1', columns); + expect(parsed).toMatchObject({ ok: false, code: 'invalid-dependent' }); + }); + + it('rejects an unparseable right-hand side', () => { + const parsed = parseTildeModel('y1 ~ a*(x1', columns); + expect(parsed).toMatchObject({ ok: false, code: 'parse-error' }); + }); + + it('rejects a model with no free parameters', () => { + const parsed = parseTildeModel('y1 ~ x1', columns); + expect(parsed).toMatchObject({ ok: false, code: 'no-parameters' }); + }); + + it('does not treat built-in constants as parameters', () => { + const parsed = parseTildeModel('y1 ~ a*sin(pi*x1) + b*e', columns); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.parameters).toEqual(['a', 'b']); + } + }); +}); + +describe('buildCannedModel', () => { + it('emits the exact model string for each family', () => { + expect(buildCannedModel('linear', 'x1', 'y1')).toBe('y1 ~ m*x1 + b'); + expect(buildCannedModel('quadratic', 'x1', 'y1')).toBe('y1 ~ a*x1^2 + b*x1 + c'); + expect(buildCannedModel('exponential', 'x1', 'y1')).toBe('y1 ~ a*exp(b*x1)'); + expect(buildCannedModel('logarithmic', 'x1', 'y1')).toBe('y1 ~ a + b*ln(x1)'); + expect(buildCannedModel('logistic', 'x1', 'y1')).toBe('y1 ~ L/(1 + exp(-k*(x1 - x0)))'); + expect(buildCannedModel('sinusoidal', 'x1', 'y1')).toBe('y1 ~ a*sin(b*x1 + c) + d'); + }); +}); + +describe('fitModel — parameter recovery', () => { + it('recovers an exact linear fit y = 2x + 1', () => { + const x1 = [0, 1, 2, 3, 4, 5]; + const y1 = x1.map((x) => 2 * x + 1); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.parameters['m']).toBeCloseTo(2, 8); + expect(fit.parameters['b']).toBeCloseTo(1, 8); + expect(fit.r2).toBeCloseTo(1, 8); + expect(fit.status).toBe('converged'); + }); + + it('recovers a quadratic y = 0.5x² − 3x + 2', () => { + const x1 = [-3, -2, -1, 0, 1, 2, 3, 4]; + const y1 = x1.map((x) => 0.5 * x ** 2 - 3 * x + 2); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ a*x1^2 + b*x1 + c')); + expect(fit.parameters['a']).toBeCloseTo(0.5, 6); + expect(fit.parameters['b']).toBeCloseTo(-3, 6); + expect(fit.parameters['c']).toBeCloseTo(2, 6); + expect(fit.status).toBe('converged'); + }); + + it('recovers an exponential y = 3·e^(0.5x)', () => { + const x1 = [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4]; + const y1 = x1.map((x) => 3 * Math.exp(0.5 * x)); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ a*exp(b*x1)')); + expect(fit.parameters['a']).toBeCloseTo(3, 6); + expect(fit.parameters['b']).toBeCloseTo(0.5, 6); + expect(fit.r2).toBeCloseTo(1, 8); + expect(fit.status).toBe('converged'); + }); + + it('recovers a logarithmic y = 1.5 + 2·ln(x)', () => { + const x1 = [0.5, 1, 2, 3, 5, 8, 13]; + const y1 = x1.map((x) => 1.5 + 2 * Math.log(x)); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ a + b*ln(x1)')); + expect(fit.parameters['a']).toBeCloseTo(1.5, 6); + expect(fit.parameters['b']).toBeCloseTo(2, 6); + expect(fit.status).toBe('converged'); + }); + + it('recovers a logistic L=10, k=1.2, x0=2', () => { + const x1 = Array.from({ length: 25 }, (_, i) => -4 + i * 0.5); + const y1 = x1.map((x) => 10 / (1 + Math.exp(-1.2 * (x - 2)))); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ L/(1 + exp(-k*(x1 - x0)))')); + expect(fit.parameters['L']).toBeCloseTo(10, 4); + expect(fit.parameters['k']).toBeCloseTo(1.2, 4); + expect(fit.parameters['x0']).toBeCloseTo(2, 4); + expect(fit.status).toBe('converged'); + }); + + it('recovers a sinusoid when given an initial guess', () => { + const x1 = Array.from({ length: 40 }, (_, i) => i * 0.25); + const y1 = x1.map((x) => 2.5 * Math.sin(1.5 * x + 0.4) + 1); + const fit = expectOk( + fitModel({ x1, y1 }, 'y1 ~ a*sin(b*x1 + c) + d', { + initialGuess: { a: 2, b: 1.4, c: 0.5, d: 0.8 }, + }), + ); + expect(fit.parameters['a']).toBeCloseTo(2.5, 4); + expect(fit.parameters['b']).toBeCloseTo(1.5, 4); + expect(fit.parameters['c']).toBeCloseTo(0.4, 4); + expect(fit.parameters['d']).toBeCloseTo(1, 4); + expect(fit.status).toBe('converged'); + }); + + it('matches linearRegression on the same data', () => { + const x1 = [1, 2, 3, 4, 5, 6]; + const y1 = [2.1, 3.9, 6.2, 7.8, 10.1, 11.7]; + const reference = linearRegression(x1, y1); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.parameters['m']).toBeCloseTo(reference.slope, 6); + expect(fit.parameters['b']).toBeCloseTo(reference.intercept, 6); + expect(fit.r2).toBeCloseTo(reference.r2, 6); + }); +}); + +describe('fitModel — hygiene and honest failure', () => { + it('reports consistent residuals, predictions and rmse', () => { + const x1 = [1, 2, 3, 4, 5]; + const y1 = [1.9, 4.2, 5.8, 8.1, 9.9]; + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.residuals).toHaveLength(5); + expect(fit.predicted).toHaveLength(5); + expect(fit.rmse).toBeGreaterThanOrEqual(0); + for (let i = 0; i < 5; i++) { + expect((fit.predicted[i] ?? Number.NaN) + (fit.residuals[i] ?? Number.NaN)).toBeCloseTo( + y1[i] ?? Number.NaN, + 10, + ); + } + }); + + it('drops non-finite rows and records a structured warning', () => { + const x1 = [0, 1, 2, Number.NaN, 4, 5]; + const y1 = [1, 3, 5, 7, Number.POSITIVE_INFINITY, 11]; + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.residuals).toHaveLength(4); + expect(fit.warnings).toContainEqual({ code: 'dropped-rows', count: 2 }); + expect(fit.parameters['m']).toBeCloseTo(2, 6); + expect(fit.parameters['b']).toBeCloseTo(1, 6); + }); + + it('fails with insufficient-data when rows do not exceed parameters', () => { + const fit = fitModel({ x1: [1, 2], y1: [2, 4] }, 'y1 ~ m*x1 + b'); + expect(fit).toMatchObject({ ok: false, status: 'insufficient-data' }); + }); + + it('fails with invalid-model for unparseable input', () => { + const fit = fitModel({ x1: [1, 2, 3], y1: [1, 2, 3] }, 'y1 a*x1'); + expect(fit).toMatchObject({ ok: false, status: 'invalid-model' }); + }); + + it('fails with diverged when the model is not evaluable at the initial guess', () => { + // ln(x1) is undefined for negative x — every row fails at any parameter value. + const fit = fitModel({ x1: [-5, -4, -3, -2], y1: [1, 2, 3, 4] }, 'y1 ~ a + b*ln(x1)'); + expect(fit).toMatchObject({ ok: false, status: 'diverged' }); + if (!fit.ok) { + expect(fit.message).toContain('row'); + } + }); + + it('never returns ok with non-finite parameters on degenerate data', () => { + const fit = fitModel({ x1: [5, 5, 5, 5, 5], y1: [1, 2, 3, 4, 5] }, 'y1 ~ m*x1 + b'); + if (fit.ok) { + for (const value of Object.values(fit.parameters)) { + expect(Number.isFinite(value)).toBe(true); + } + expect(Number.isFinite(fit.r2)).toBe(true); + expect(Number.isFinite(fit.rmse)).toBe(true); + } else { + expect(fit.status).toBe('singular'); + } + }); + + it('reports zero-variance r2 = 0 with a warning on constant y', () => { + const fit = expectOk(fitModel({ x1: [1, 2, 3, 4], y1: [7, 7, 7, 7] }, 'y1 ~ m*x1 + b')); + expect(fit.r2).toBe(0); + expect(fit.warnings).toContainEqual({ code: 'zero-variance' }); + }); + + it('clamps parameters to bounds and records a warning', () => { + const x1 = [0, 1, 2, 3, 4, 5]; + const y1 = x1.map((x) => 2 * x); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b', { bounds: { m: { max: 1 } } })); + expect(fit.parameters['m']).toBeLessThanOrEqual(1); + expect(fit.warnings).toContainEqual({ code: 'bound-hit', name: 'm' }); + }); + + it('predict closure matches predicted[] on training rows', () => { + const x1 = [0, 1, 2, 3, 4]; + const y1 = x1.map((x) => 3 * Math.exp(0.5 * x)); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ a*exp(b*x1)')); + x1.forEach((x, i) => { + expect(fit.predict({ x1: x })).toBeCloseTo(fit.predicted[i] ?? Number.NaN, 10); + }); + }); + + it('predict returns NaN outside the model domain instead of throwing', () => { + const x1 = [1, 2, 3, 4, 5]; + const y1 = x1.map((x) => 1 + 2 * Math.log(x)); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ a + b*ln(x1)')); + expect(Number.isNaN(fit.predict({ x1: -1 }))).toBe(true); + }); + + it('reports standard errors when the fit is overdetermined', () => { + const x1 = [1, 2, 3, 4, 5, 6]; + const y1 = [2.1, 3.9, 6.2, 7.8, 10.1, 11.7]; + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.standardErrors).toBeDefined(); + expect(fit.standardErrors?.['m']).toBeGreaterThan(0); + expect(fit.standardErrors?.['b']).toBeGreaterThan(0); + }); +}); + +describe('fitModel — property-based linear recovery', () => { + it('recovers arbitrary slope and intercept from noiseless data', () => { + const x1 = [-3, -1.5, 0, 1, 2.5, 4, 5.5, 7]; + fc.assert( + fc.property( + fc.double({ min: -10, max: 10, noNaN: true }), + fc.double({ min: -10, max: 10, noNaN: true }), + (slope, intercept) => { + const y1 = x1.map((x) => slope * x + intercept); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.parameters['m']).toBeCloseTo(slope, 6); + expect(fit.parameters['b']).toBeCloseTo(intercept, 6); + }, + ), + { numRuns: 50 }, + ); + }); +}); diff --git a/packages/math-engine/src/stats/fit.ts b/packages/math-engine/src/stats/fit.ts new file mode 100644 index 00000000..547f0c73 --- /dev/null +++ b/packages/math-engine/src/stats/fit.ts @@ -0,0 +1,636 @@ +/** + * Arbitrary-Model Regression (Desmos-style tilde fitting) + * + * Fits user-supplied models of the form `y1 ~ a*exp(b*x1)` to columnar data + * using damped Levenberg-Marquardt with analytic Jacobians obtained from the + * symbolic differentiation engine. The tilde is split in this module — it is + * never fed to the expression parser (mathjs has no `~` operator). + * + * Honesty contract: a fit NEVER silently succeeds. `FitResult` is a + * discriminated union whose failure arm carries an explicit status + * ('invalid-model' | 'insufficient-data' | 'singular' | 'diverged'), and the + * success arm reports 'converged' vs 'max-iterations' plus structured + * warnings (dropped rows, parameters pinned at bounds, zero variance). + * + * @module stats/fit + */ + +import { solveLinearSystem } from '../matrix'; +import { + type EvaluationResult, + type ExpressionNode, + evaluate, + extractVariables, + isFunctionNode, + parse, +} from '../parser'; +import { differentiate, simplifyDerivative } from '../symbolic/differentiate'; +import { mean, median } from './descriptive'; +import { linearRegression } from './regression'; + +/** Valid column / parameter identifier (also mirrored by the web data table). */ +const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; + +/** Error codes produced by {@link parseTildeModel}. */ +export type TildeModelErrorCode = + | 'no-tilde' + | 'multiple-tildes' + | 'invalid-dependent' + | 'parse-error' + | 'no-parameters'; + +/** Successfully parsed tilde model (the `ok: true` arm of {@link ParsedModel}). */ +export interface TildeModel { + /** Column name on the left of the tilde (the dependent variable). */ + readonly dependent: string; + /** Parsed AST of the right-hand side. */ + readonly rhsAst: ExpressionNode; + /** Trimmed right-hand-side source text. */ + readonly rhsText: string; + /** Free parameters to fit (symbols that are neither columns nor built-in constants), sorted. */ + readonly parameters: readonly string[]; + /** Data columns referenced on the right-hand side, sorted. */ + readonly regressors: readonly string[]; +} + +/** + * Result of parsing a tilde model expression. + * A discriminated union so UI callers can render error state without try/catch. + */ +export type ParsedModel = + | ({ readonly ok: true } & TildeModel) + | { readonly ok: false; readonly code: TildeModelErrorCode; readonly message: string }; + +/** + * Splits a tilde model like `y1 ~ a*exp(b*x1)` into dependent column, + * right-hand-side AST, fit parameters and regressors. + * + * @param input - Full model text containing exactly one `~` + * @param columns - Data column names; the LHS must be one of these + */ +export function parseTildeModel(input: string, columns: readonly string[]): ParsedModel { + const parts = input.split('~'); + if (parts.length === 1) { + return { + ok: false, + code: 'no-tilde', + message: 'Model must contain a "~" separating the dependent column from the formula', + }; + } + if (parts.length > 2) { + return { + ok: false, + code: 'multiple-tildes', + message: 'Model must contain exactly one "~"', + }; + } + + const dependent = (parts[0] ?? '').trim(); + const rhsText = (parts[1] ?? '').trim(); + + if (!IDENTIFIER_PATTERN.test(dependent) || !columns.includes(dependent)) { + return { + ok: false, + code: 'invalid-dependent', + message: `Left side of "~" must be a data column (one of: ${columns.join(', ')})`, + }; + } + + let rhsAst: ExpressionNode; + try { + rhsAst = parse(rhsText); + } catch (error) { + return { + ok: false, + code: 'parse-error', + message: error instanceof Error ? error.message : `Failed to parse formula: ${rhsText}`, + }; + } + + // extractVariables already excludes built-in constants (pi/π, e, tau/τ, i, phi/φ). + const symbols = extractVariables(rhsAst); + const columnSet = new Set(columns); + const parameters = [...symbols].filter((s) => !columnSet.has(s)).sort(); + const regressors = [...symbols].filter((s) => columnSet.has(s)).sort(); + + if (parameters.length === 0) { + return { + ok: false, + code: 'no-parameters', + message: 'Formula has no free parameters to fit — every symbol is a data column', + }; + } + + return { ok: true, dependent, rhsAst, rhsText, parameters, regressors }; +} + +/** Canned model families with prebaked formulas and smart initial guesses. */ +export type CannedModelKind = + | 'linear' + | 'quadratic' + | 'exponential' + | 'logarithmic' + | 'logistic' + | 'sinusoidal'; + +/** + * Builds the model string for a canned model family over the given columns. + * + * @example buildCannedModel('exponential', 'x1', 'y1') // 'y1 ~ a*exp(b*x1)' + */ +export function buildCannedModel(kind: CannedModelKind, xColumn: string, yColumn: string): string { + switch (kind) { + case 'linear': + return `${yColumn} ~ m*${xColumn} + b`; + case 'quadratic': + return `${yColumn} ~ a*${xColumn}^2 + b*${xColumn} + c`; + case 'exponential': + return `${yColumn} ~ a*exp(b*${xColumn})`; + case 'logarithmic': + return `${yColumn} ~ a + b*ln(${xColumn})`; + case 'logistic': + return `${yColumn} ~ L/(1 + exp(-k*(${xColumn} - x0)))`; + case 'sinusoidal': + return `${yColumn} ~ a*sin(b*${xColumn} + c) + d`; + } +} + +/** Optional per-parameter box constraints. */ +export interface ParameterBounds { + readonly min?: number; + readonly max?: number; +} + +/** Options for {@link fitModel}. */ +export interface FitOptions { + /** Maximum Levenberg-Marquardt iterations (default 100). */ + readonly maxIterations?: number; + /** Relative convergence tolerance on SSR decrease and step size (default 1e-10). */ + readonly tolerance?: number; + /** Initial LM damping factor λ (default 1e-3). */ + readonly lambdaInit?: number; + /** Per-parameter starting values; wins over the built-in smart seeds. */ + readonly initialGuess?: Readonly>; + /** Per-parameter box bounds; trial steps are clamped and a warning recorded. */ + readonly bounds?: Readonly>; +} + +/** + * Structured, translatable warning attached to a successful fit. + * (Structured rather than plain strings so UIs can localize with ICU args.) + */ +export type FitWarning = + | { readonly code: 'dropped-rows'; readonly count: number } + | { readonly code: 'bound-hit'; readonly name: string } + | { readonly code: 'zero-variance' }; + +/** Successful fit. */ +export interface FitSuccess { + readonly ok: true; + /** Fitted parameter values keyed by parameter name. */ + readonly parameters: Record; + /** Asymptotic standard errors (omitted when JᵀJ is singular or n ≤ p). */ + readonly standardErrors?: Record; + /** Coefficient of determination in the original data space. */ + readonly r2: number; + /** Root mean squared error √(SSR/n). */ + readonly rmse: number; + /** Per-row residuals yᵢ − ŷᵢ (rows with non-finite inputs are excluded). */ + readonly residuals: number[]; + /** Per-row model predictions ŷᵢ. */ + readonly predicted: number[]; + /** LM iterations executed. */ + readonly iterations: number; + /** 'converged' or 'max-iterations' — the latter is a visible caution, never silent. */ + readonly status: 'converged' | 'max-iterations'; + readonly warnings: FitWarning[]; + /** The parsed model that was fitted. */ + readonly model: TildeModel; + /** Evaluates the fitted model at arbitrary regressor values (NaN on domain errors). */ + readonly predict: (vars: Record) => number; +} + +/** Failed fit — always carries an explicit machine-readable status. */ +export interface FitFailure { + readonly ok: false; + readonly status: 'invalid-model' | 'insufficient-data' | 'singular' | 'diverged'; + readonly message: string; + readonly issues?: string[]; +} + +/** Discriminated fit result: check `.ok` before reading fit statistics. */ +export type FitResult = FitSuccess | FitFailure; + +/** Coerces an evaluator result to a finite number, or null on any failure. */ +function toFiniteNumber(result: EvaluationResult): number | null { + if (!result.success) return null; + const raw = result.value; + const value = typeof raw === 'bigint' ? Number(raw) : raw; + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +/** True when the AST contains a function call with the given name. */ +function containsFunction(node: ExpressionNode, fn: string): boolean { + if (isFunctionNode(node) && node.fn === fn) return true; + return node.args?.some((arg) => containsFunction(arg, fn)) ?? false; +} + +/** Largest absolute value in a non-empty array. */ +function maxAbs(values: readonly number[]): number { + let max = 0; + for (const v of values) { + const a = Math.abs(v); + if (a > max) max = a; + } + return max; +} + +/** + * Smart initial guesses keyed off the canned model shapes; every parameter + * defaults to 1 when no heuristic applies. + * + * Note: the sinusoidal frequency seed b = 2π/(xMax − xMin) is a heuristic — + * sinusoidal fits are strongly guess-sensitive; pass `initialGuess` when the + * frequency is roughly known. + */ +function defaultSeeds( + model: TildeModel, + x: readonly number[] | null, + y: readonly number[], +): Record { + const seeds: Record = {}; + for (const p of model.parameters) seeds[p] = 1; + const paramSet = new Set(model.parameters); + + if (paramSet.has('L') && paramSet.has('k') && paramSet.has('x0')) { + // Logistic L/(1 + exp(-k*(x - x0))) + seeds['L'] = 1.05 * Math.max(...y); + seeds['k'] = 1; + seeds['x0'] = x && x.length > 0 ? median([...x]) : 0; + } else if ( + paramSet.has('a') && + paramSet.has('b') && + paramSet.has('c') && + paramSet.has('d') && + containsFunction(model.rhsAst, 'sin') + ) { + // Sinusoidal a*sin(b*x + c) + d + const yMax = Math.max(...y); + const yMin = Math.min(...y); + seeds['a'] = (yMax - yMin) / 2 || 1; + seeds['d'] = mean([...y]); + seeds['c'] = 0; + if (x && x.length > 1) { + const xMax = Math.max(...x); + const xMin = Math.min(...x); + seeds['b'] = xMax > xMin ? (2 * Math.PI) / (xMax - xMin) : 1; + } + } else if (paramSet.has('a') && paramSet.has('b') && containsFunction(model.rhsAst, 'exp')) { + // Exponential-family a*exp(b*x): seed from log-linear regression when possible. + let seeded = false; + if (x && x.length >= 2 && y.every((v) => v > 0)) { + try { + const logFit = linearRegression([...x], y.map(Math.log)); + if (Number.isFinite(logFit.slope) && Number.isFinite(logFit.intercept)) { + seeds['a'] = Math.exp(logFit.intercept); + seeds['b'] = logFit.slope; + seeded = true; + } + } catch { + // degenerate x — fall through to the magnitude seed + } + } + if (!seeded) { + seeds['a'] = maxAbs(y) || 1; + seeds['b'] = 0.1; + } + } + + return seeds; +} + +/** One fully evaluated LM point: predictions, Jacobian and SSR. */ +interface EvalPoint { + readonly f: number[]; + readonly jacobian: number[][]; + readonly ssr: number; +} + +/** + * Fits `model` (tilde syntax) to columnar `data` with damped Levenberg-Marquardt. + * + * - Analytic Jacobians via symbolic differentiation (no finite differences). + * - Rows containing non-finite values in any used column are dropped (warned). + * - Trial steps are clamped to `options.bounds` (warned once per parameter). + * - Never returns a silent bad fit: failures carry an explicit status, and a + * non-converged success is flagged 'max-iterations'. + */ +export function fitModel( + data: Readonly>, + model: string, + options?: FitOptions, +): FitResult { + const columns = Object.keys(data); + const parsed = parseTildeModel(model, columns); + if (!parsed.ok) { + return { + ok: false, + status: 'invalid-model', + message: parsed.message, + issues: [parsed.code], + }; + } + + const maxIterations = options?.maxIterations ?? 100; + const tolerance = options?.tolerance ?? 1e-10; + let lambda = options?.lambdaInit ?? 1e-3; + + // Analytic Jacobian ASTs, computed once per parameter. + const parameters = parsed.parameters; + const p = parameters.length; + let jacobianAsts: ExpressionNode[]; + try { + jacobianAsts = parameters.map((name) => simplifyDerivative(differentiate(parsed.rhsAst, name))); + } catch (error) { + return { + ok: false, + status: 'invalid-model', + message: `Model is not differentiable with respect to its parameters: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + // Assemble finite rows over the used columns. + const usedColumns = [parsed.dependent, ...parsed.regressors]; + const rowCount = Math.min(...usedColumns.map((c) => data[c]?.length ?? 0)); + const rows: Record[] = []; + const y: number[] = []; + for (let i = 0; i < rowCount; i++) { + const row: Record = {}; + let finite = true; + for (const c of usedColumns) { + const v = data[c]?.[i]; + if (typeof v !== 'number' || !Number.isFinite(v)) { + finite = false; + break; + } + row[c] = v; + } + if (!finite) continue; + rows.push(row); + y.push(row[parsed.dependent] ?? Number.NaN); + } + + const warnings: FitWarning[] = []; + const dropped = rowCount - rows.length; + if (dropped > 0) { + warnings.push({ code: 'dropped-rows', count: dropped }); + } + + const n = rows.length; + if (n <= p) { + return { + ok: false, + status: 'insufficient-data', + message: `Need more than ${p} finite data rows to fit ${p} parameter${p === 1 ? '' : 's'} (got ${n})`, + }; + } + + // Initial parameter vector: user guess > smart seeds > 1, clamped into bounds. + const firstRegressor = parsed.regressors[0]; + const xForSeeds = + firstRegressor === undefined ? null : rows.map((row) => row[firstRegressor] ?? Number.NaN); + const seeds = defaultSeeds(parsed, xForSeeds, y); + const boundsFor = (name: string): ParameterBounds | undefined => options?.bounds?.[name]; + const clamp = (name: string, value: number): number => { + const b = boundsFor(name); + let v = value; + if (b?.min !== undefined && v < b.min) v = b.min; + if (b?.max !== undefined && v > b.max) v = b.max; + return v; + }; + let params = parameters.map((name) => + clamp(name, options?.initialGuess?.[name] ?? seeds[name] ?? 1), + ); + + const toParamRecord = (vec: readonly number[]): Record => { + const record: Record = {}; + parameters.forEach((name, j) => { + record[name] = vec[j] ?? Number.NaN; + }); + return record; + }; + + /** Evaluates model + Jacobian at a parameter vector; string on failure. */ + const evalPoint = (vec: readonly number[]): EvalPoint | string => { + const paramRecord = toParamRecord(vec); + const f: number[] = []; + const jacobian: number[][] = []; + let ssr = 0; + for (let i = 0; i < n; i++) { + const variables = { ...rows[i], ...paramRecord }; + const fi = toFiniteNumber(evaluate(parsed.rhsAst, { variables })); + if (fi === null) { + return `model evaluation failed at data row ${i + 1}`; + } + const grad: number[] = []; + for (let j = 0; j < p; j++) { + const ast = jacobianAsts[j]; + const gj = ast === undefined ? null : toFiniteNumber(evaluate(ast, { variables })); + if (gj === null) { + return `derivative with respect to "${parameters[j]}" failed at data row ${i + 1}`; + } + grad.push(gj); + } + f.push(fi); + jacobian.push(grad); + const r = (y[i] ?? Number.NaN) - fi; + ssr += r * r; + } + return { f, jacobian, ssr }; + }; + + let current = evalPoint(params); + if (typeof current === 'string') { + return { + ok: false, + status: 'diverged', + message: `Model could not be evaluated at the initial guess: ${current}`, + }; + } + + let iterations = 0; + let status: 'converged' | 'max-iterations' = 'max-iterations'; + let consecutiveSingular = 0; + const boundHits = new Set(); + + for (let iter = 1; iter <= maxIterations; iter++) { + iterations = iter; + + // Normal equations: (JᵀJ + λ·diag(JᵀJ))·δ = Jᵀr + const jtj: number[][] = Array.from({ length: p }, () => new Array(p).fill(0)); + const jtr = new Array(p).fill(0); + for (let i = 0; i < n; i++) { + const gradRow = current.jacobian[i]; + const fi = current.f[i]; + if (gradRow === undefined || fi === undefined) continue; + const ri = (y[i] ?? Number.NaN) - fi; + for (let j = 0; j < p; j++) { + const gj = gradRow[j] ?? 0; + jtr[j] = (jtr[j] ?? 0) + gj * ri; + const jtjRow = jtj[j]; + if (jtjRow === undefined) continue; + for (let k = j; k < p; k++) { + jtjRow[k] = (jtjRow[k] ?? 0) + gj * (gradRow[k] ?? 0); + } + } + } + // Mirror the upper triangle. + for (let j = 0; j < p; j++) { + for (let k = 0; k < j; k++) { + const upper = jtj[k]?.[j]; + const rowJ = jtj[j]; + if (rowJ !== undefined && upper !== undefined) rowJ[k] = upper; + } + } + + const damped = jtj.map((row, j) => + row.map((v, k) => (j === k ? v + lambda * Math.max(v, 1e-12) : v)), + ); + + const delta = solveLinearSystem(damped, jtr); + if (delta === null) { + consecutiveSingular += 1; + if (consecutiveSingular >= 5) { + return { + ok: false, + status: 'singular', + message: + 'Normal equations are singular — parameters are likely not identifiable from this data (e.g. all x values identical or redundant parameters)', + }; + } + lambda = Math.min(lambda * 8, 1e14); + continue; + } + consecutiveSingular = 0; + + // Trial step, clamped to bounds. + const trial = params.map((v, j) => { + const name = parameters[j]; + const stepped = v + (delta[j] ?? 0); + if (name === undefined) return stepped; + const clamped = clamp(name, stepped); + if (clamped !== stepped) boundHits.add(name); + return clamped; + }); + if (trial.some((v) => !Number.isFinite(v))) { + lambda = Math.min(lambda * 8, 1e14); + continue; + } + + const stepTiny = trial.every( + (v, j) => + Math.abs(v - (params[j] ?? 0)) <= tolerance * (Math.abs(params[j] ?? 0) + tolerance), + ); + + const trialPoint = evalPoint(trial); + if (typeof trialPoint === 'string') { + // Reject steps into non-evaluable territory; keep the last accepted point. + lambda = Math.min(lambda * 8, 1e14); + continue; + } + + if (trialPoint.ssr <= current.ssr) { + const ssrDrop = current.ssr - trialPoint.ssr; + params = trial; + current = trialPoint; + lambda = Math.max(lambda * 0.3, 1e-14); + if (ssrDrop <= tolerance * (current.ssr + tolerance) || stepTiny) { + status = 'converged'; + break; + } + } else { + lambda = Math.min(lambda * 8, 1e14); + if (stepTiny) { + // The proposed step is negligible and does not improve SSR: local minimum. + status = 'converged'; + break; + } + } + } + + // Fit statistics in the original data space. + const ssr = current.ssr; + const yMean = mean(y); + let tss = 0; + for (const yi of y) tss += (yi - yMean) ** 2; + let r2: number; + if (tss === 0) { + r2 = 0; + warnings.push({ code: 'zero-variance' }); + } else { + r2 = 1 - ssr / tss; + } + const rmse = Math.sqrt(ssr / n); + const predicted = [...current.f]; + const residuals = y.map((yi, i) => yi - (predicted[i] ?? Number.NaN)); + + for (const name of [...boundHits].sort()) { + warnings.push({ code: 'bound-hit', name }); + } + + // Asymptotic standard errors from (JᵀJ)⁻¹ · SSR/(n − p), when invertible. + let standardErrors: Record | undefined; + if (n > p) { + const jtj: number[][] = Array.from({ length: p }, () => new Array(p).fill(0)); + for (let i = 0; i < n; i++) { + const gradRow = current.jacobian[i]; + if (gradRow === undefined) continue; + for (let j = 0; j < p; j++) { + const jtjRow = jtj[j]; + if (jtjRow === undefined) continue; + for (let k = 0; k < p; k++) { + jtjRow[k] = (jtjRow[k] ?? 0) + (gradRow[j] ?? 0) * (gradRow[k] ?? 0); + } + } + } + const variance = ssr / (n - p); + const errors: Record = {}; + let invertible = true; + for (let j = 0; j < p; j++) { + const unit = new Array(p).fill(0); + unit[j] = 1; + const col = solveLinearSystem(jtj, unit); + const diag = col?.[j]; + if (col === null || diag === undefined || diag < 0) { + invertible = false; + break; + } + const name = parameters[j]; + if (name !== undefined) errors[name] = Math.sqrt(diag * variance); + } + if (invertible) standardErrors = errors; + } + + const finalParams = toParamRecord(params); + const predict = (vars: Record): number => { + const result = evaluate(parsed.rhsAst, { variables: { ...vars, ...finalParams } }); + return toFiniteNumber(result) ?? Number.NaN; + }; + + return { + ok: true, + parameters: finalParams, + ...(standardErrors !== undefined ? { standardErrors } : {}), + r2, + rmse, + residuals, + predicted, + iterations, + status, + warnings, + model: parsed, + predict, + }; +} diff --git a/packages/math-engine/src/stats/index.ts b/packages/math-engine/src/stats/index.ts index c5a79053..c23a8576 100644 --- a/packages/math-engine/src/stats/index.ts +++ b/packages/math-engine/src/stats/index.ts @@ -4,6 +4,7 @@ * Comprehensive statistical analysis toolkit including: * - Descriptive statistics (mean, median, mode, variance, standard deviation, etc.) * - Regression analysis (linear, polynomial, exponential) + * - Arbitrary-model tilde regression (Levenberg-Marquardt with analytic Jacobians) * - Correlation and covariance measures * * All functions are pure, immutable, and numerically stable. @@ -32,6 +33,22 @@ export { sum, variance, } from './descriptive'; +// Arbitrary-model tilde regression (nonlinear least squares) +export { + buildCannedModel, + type CannedModelKind, + type FitFailure, + type FitOptions, + type FitResult, + type FitSuccess, + type FitWarning, + fitModel, + type ParameterBounds, + type ParsedModel, + parseTildeModel, + type TildeModel, + type TildeModelErrorCode, +} from './fit'; // Regression analysis export { type ExponentialRegressionResult, From 312caab4a355ec1cab44b2c9acbcdad010b92f0f Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:17:24 -0500 Subject: [PATCH 2/3] feat(web): Data & Regression plot tab with draggable points and tilde-model fitting Fifth /plot tab composed entirely in components/plots/regression/*: - DataTable: native-table columnar editor with spreadsheet TSV clipboard paste (anchored at the focused cell, auto-growing rows/columns), editable identifier-validated column headers (user-invalid + aria-invalid), field-sizing-content tabular-nums cells, Enter-to-next-row. - DataPointsOverlay: SVG overlay over Plot2D reusing Annotations.tsx's exact math-to-pixel mapping; pointer-capture dragging with finger-sized hit targets, keyboard arrow nudging, muted residual segments. - RegressionModelInput: tilde-syntax row with live parse feedback and six canned-model chips (buildCannedModel). - FitStatsPanel: tabular-nums parameters (with standard errors), R2/RMSE/ iterations, converged vs max-iterations badge, localized structured warnings, destructive alert for explicit fit failures - never silent. - DataRegressionTab: derived parse/fit computed in render (React Compiler memoizes - no manual memo hooks), useDeferredValue defers the LM refit during drag, fitted curve added as a Plot2DCartesianConfig function only for single-regressor models (NaN = renderer break markers), viewport auto-fit to data extent +12% on table edits. - Plot2D: new opt-in syncViewportToConfig prop - pushes external config.viewport changes into the interaction controller so the first drag after an auto-fit no longer snaps back to the stale seed viewport (legacy uncontrolled contract unchanged for existing tabs). - i18n: full plots.regression.* key set + plots.tab.dataRegression in all 8 locales, properly translated. - Tests: component behavior (TSV paste, canned chips, converged stats, invalid-model failure, aria-invalid headers, degenerate-data honesty) and jest-axe accessibility (empty + fitted states, native table semantics). Co-Authored-By: Claude Fable 5 --- .../data-regression.a11y.test.tsx | 52 ++++ apps/web/app/[locale]/plot/page.tsx | 14 + apps/web/components/plots/Plot2D.tsx | 30 ++ apps/web/components/plots/index.ts | 13 + .../plots/regression/DataPointsOverlay.tsx | 189 ++++++++++++ .../regression/DataRegressionTab.test.tsx | 91 ++++++ .../plots/regression/DataRegressionTab.tsx | 273 ++++++++++++++++++ .../components/plots/regression/DataTable.tsx | 252 ++++++++++++++++ .../plots/regression/FitStatsPanel.tsx | 147 ++++++++++ .../plots/regression/RegressionModelInput.tsx | 108 +++++++ apps/web/messages/de.json | 49 +++- apps/web/messages/en.json | 49 +++- apps/web/messages/es.json | 49 +++- apps/web/messages/fr.json | 49 +++- apps/web/messages/ja.json | 49 +++- apps/web/messages/ru.json | 49 +++- apps/web/messages/uk.json | 49 +++- apps/web/messages/zh.json | 49 +++- 18 files changed, 1553 insertions(+), 8 deletions(-) create mode 100644 apps/web/__tests__/accessibility/data-regression.a11y.test.tsx create mode 100644 apps/web/components/plots/regression/DataPointsOverlay.tsx create mode 100644 apps/web/components/plots/regression/DataRegressionTab.test.tsx create mode 100644 apps/web/components/plots/regression/DataRegressionTab.tsx create mode 100644 apps/web/components/plots/regression/DataTable.tsx create mode 100644 apps/web/components/plots/regression/FitStatsPanel.tsx create mode 100644 apps/web/components/plots/regression/RegressionModelInput.tsx diff --git a/apps/web/__tests__/accessibility/data-regression.a11y.test.tsx b/apps/web/__tests__/accessibility/data-regression.a11y.test.tsx new file mode 100644 index 00000000..26a90ee9 --- /dev/null +++ b/apps/web/__tests__/accessibility/data-regression.a11y.test.tsx @@ -0,0 +1,52 @@ +/** + * Accessibility tests for the Data & Regression tab (jest-axe). + * + * Guard: native semantics with caption/headers, labelled inputs and + * an accessibly-named residual toggle — verified with axe, not just biome. + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { axe } from 'jest-axe'; +import { describe, expect, it, vi } from 'vitest'; +import { DataRegressionTab } from '@/components/plots'; + +vi.mock('@/components/plots/Plot2D', () => ({ + Plot2D: () => , +})); + +describe('Data & Regression accessibility (WCAG 2.2)', () => { + it('empty tab has no axe violations', async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('fitted state (stats panel + toggle) has no axe violations', async () => { + const { container } = render(); + + const table = screen.getByRole('table', { name: 'dataTitle' }); + fireEvent.paste(table, { + clipboardData: { getData: () => '1\t3\n2\t5\n3\t7\n4\t9' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'canned.linear' })); + await screen.findByText('status.converged'); + + // Residual toggle is a labelled switch. + expect(screen.getByRole('switch', { name: 'residualsToggle' })).toBeInTheDocument(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('data table uses native semantics: caption and column headers', () => { + render(); + + const table = screen.getByRole('table', { name: 'dataTitle' }); + expect(table.querySelector('caption')).not.toBeNull(); + expect(screen.getAllByRole('columnheader').length).toBeGreaterThanOrEqual(2); + // Every data cell input is labelled. + for (const input of screen.getAllByRole('textbox')) { + expect(input).toHaveAccessibleName(); + } + }); +}); diff --git a/apps/web/app/[locale]/plot/page.tsx b/apps/web/app/[locale]/plot/page.tsx index 392fb6dc..f44f9c4a 100644 --- a/apps/web/app/[locale]/plot/page.tsx +++ b/apps/web/app/[locale]/plot/page.tsx @@ -29,6 +29,7 @@ import { useCallback, useMemo, useState } from 'react'; import { VariableSliders } from '@/components/plot/variable-sliders'; import { type AnalysisFunction, + DataRegressionTab, type FunctionDefinition, FunctionInput, Plot2D, @@ -813,6 +814,12 @@ export default function PlotsExamplesPage() { > {t('tab.3dSurface')} + + {t('tab.dataRegression')} + @@ -1209,6 +1216,13 @@ const config = { + + {/* ---------------------------------------------------------------- + DATA & REGRESSION TAB + ---------------------------------------------------------------- */} + + + {/* Feature cards */} diff --git a/apps/web/components/plots/Plot2D.tsx b/apps/web/components/plots/Plot2D.tsx index 38c4e606..4799a019 100644 --- a/apps/web/components/plots/Plot2D.tsx +++ b/apps/web/components/plots/Plot2D.tsx @@ -36,6 +36,15 @@ export interface Plot2DProps { onCanvasReady?: (canvas: HTMLCanvasElement) => void; /** When true, renders the annotation toolbar and overlay. Defaults to false. */ enableAnnotations?: boolean; + /** + * Controlled-viewport mode: when true, external changes to `config.viewport` + * are pushed into the interaction controller so the next drag continues from + * the new viewport instead of snapping back to the controller's stale seed. + * Use when the parent feeds `onViewportChange` back into `config.viewport` + * (e.g. the Data & Regression tab's auto-fit). Defaults to false, preserving + * the legacy contract where `config.viewport` is only the initial view. + */ + syncViewportToConfig?: boolean; } /** @@ -50,6 +59,7 @@ export function Plot2D({ onViewportChange, onCanvasReady, enableAnnotations = false, + syncViewportToConfig = false, }: Plot2DProps) { const canvasRef = useRef(null); const containerRef = useRef(null); @@ -356,6 +366,26 @@ export function Plot2D({ // config re-render) and read config via the stable ref inside the handler. }, [isReady, enableInteractions, config.type]); + // Controlled-viewport mode: push external config.viewport changes into the + // controller. Value-compared, so the echo from the controller's own events + // (viewport state fed back into config by the parent) terminates immediately. + useEffect(() => { + if (!syncViewportToConfig) return; + if (config.type !== '2d-cartesian' && config.type !== '2d-parametric') return; + const controller = controllerRef.current; + if (!controller) return; + const target = config.viewport; + const current = controller.getViewport(); + if ( + current.xMin !== target.xMin || + current.xMax !== target.xMax || + current.yMin !== target.yMin || + current.yMax !== target.yMax + ) { + controller.setViewport(target); + } + }, [syncViewportToConfig, config]); + // --- Fix 1: Debounced auto-refresh when config changes --- // Use a ref to hold the pending timer so we can clear it on cleanup. const debounceTimerRef = useRef | null>(null); diff --git a/apps/web/components/plots/index.ts b/apps/web/components/plots/index.ts index 07819626..136fbd7d 100644 --- a/apps/web/components/plots/index.ts +++ b/apps/web/components/plots/index.ts @@ -33,6 +33,19 @@ export type { PolarAnalysisFunction, PolarAnalysisPanelProps } from './PolarAnal export { PolarAnalysisPanel } from './PolarAnalysisPanel'; export type { PolarAxisLabelsProps } from './PolarAxisLabels'; export { PolarAxisLabels } from './PolarAxisLabels'; +export type { + DataPointsOverlayProps, + OverlayPoint, + OverlayViewport, +} from './regression/DataPointsOverlay'; +export { DataPointsOverlay } from './regression/DataPointsOverlay'; +export { DataRegressionTab } from './regression/DataRegressionTab'; +export type { DataTableProps, DataTableValue } from './regression/DataTable'; +export { DataTable } from './regression/DataTable'; +export type { FitStatsPanelProps } from './regression/FitStatsPanel'; +export { FitStatsPanel } from './regression/FitStatsPanel'; +export type { ModelParseState, RegressionModelInputProps } from './regression/RegressionModelInput'; +export { RegressionModelInput } from './regression/RegressionModelInput'; export type { SurfaceEditor3DProps } from './SurfaceEditor3D'; export { SurfaceEditor3D } from './SurfaceEditor3D'; export { WebGLHeatmap } from './webgl-heatmap'; diff --git a/apps/web/components/plots/regression/DataPointsOverlay.tsx b/apps/web/components/plots/regression/DataPointsOverlay.tsx new file mode 100644 index 00000000..678e6667 --- /dev/null +++ b/apps/web/components/plots/regression/DataPointsOverlay.tsx @@ -0,0 +1,189 @@ +'use client'; + +/** + * SVG overlay that renders draggable data points (and optional residual + * segments) on top of Plot2D. + * + * The WebGL renderer has no scatter/marker support, so points live in an + * absolutely-positioned SVG using the exact math→pixel mapping that + * Annotations.tsx uses; residuals are muted secondary marks per dataviz + * fundamentals (single shared axis, restrained styling). + * + * @module components/plots/regression/DataPointsOverlay + */ + +import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; + +export interface OverlayViewport { + xMin: number; + xMax: number; + yMin: number; + yMax: number; +} + +export interface OverlayPoint { + x: number; + y: number; + /** Index of the source row in the data table. */ + row: number; +} + +export interface DataPointsOverlayProps { + points: readonly OverlayPoint[]; + viewport: OverlayViewport; + /** Accessible title of the overlay (svg ). */ + label: string; + /** Fill color of the data points. */ + color: string; + /** Predictor for residual segments (present only for single-regressor fits). */ + residuals?: { predict: (x: number) => number }; + showResiduals: boolean; + onPointMove: (row: number, x: number, y: number) => void; + /** Accessible label for a point, e.g. "Data point (2, 4.5)". */ + pointLabel: (x: number, y: number) => string; +} + +// Identical mapping to Annotations.tsx (mathToPixelX/mathToPixelY) so points +// land exactly on the WebGL-rendered curve. +function mathToPixelX(x: number, viewport: OverlayViewport, width: number): number { + return ((x - viewport.xMin) / (viewport.xMax - viewport.xMin)) * width; +} + +function mathToPixelY(y: number, viewport: OverlayViewport, height: number): number { + // Math Y increases upward; screen Y increases downward. + return height - ((y - viewport.yMin) / (viewport.yMax - viewport.yMin)) * height; +} + +export function DataPointsOverlay({ + points, + viewport, + label, + color, + residuals, + showResiduals, + onPointMove, + pointLabel, +}: DataPointsOverlayProps) { + const svgRef = useRef<SVGSVGElement>(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + // The row being dragged; a ref because dragging must not re-render per move. + const dragRowRef = useRef<number | null>(null); + + useEffect(() => { + const svg = svgRef.current; + if (!svg) return; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setSize({ width: entry.contentRect.width, height: entry.contentRect.height }); + } + }); + observer.observe(svg); + return () => observer.disconnect(); + }, []); + + const { width, height } = size; + if (width === 0 || height === 0) { + return ( + <svg + ref={svgRef} + className="absolute inset-0 size-full touch-none pointer-events-none" + aria-hidden="true" + /> + ); + } + + const toMath = (clientX: number, clientY: number): { x: number; y: number } => { + const rect = svgRef.current?.getBoundingClientRect(); + if (!rect) return { x: Number.NaN, y: Number.NaN }; + const px = clientX - rect.left; + const py = clientY - rect.top; + return { + x: viewport.xMin + (px / rect.width) * (viewport.xMax - viewport.xMin), + y: viewport.yMax - (py / rect.height) * (viewport.yMax - viewport.yMin), + }; + }; + + const handleKeyDown = (event: KeyboardEvent<SVGGElement>, point: OverlayPoint) => { + const stepX = (viewport.xMax - viewport.xMin) / 200; + const stepY = (viewport.yMax - viewport.yMin) / 200; + let dx = 0; + let dy = 0; + if (event.key === 'ArrowLeft') dx = -stepX; + else if (event.key === 'ArrowRight') dx = stepX; + else if (event.key === 'ArrowUp') dy = stepY; + else if (event.key === 'ArrowDown') dy = -stepY; + else return; + event.preventDefault(); + onPointMove(point.row, point.x + dx, point.y + dy); + }; + + return ( + <svg ref={svgRef} className="absolute inset-0 size-full touch-none pointer-events-none"> + <title>{label} + {/* Residual segments: muted secondary marks from each point to the fitted curve. */} + {showResiduals && + residuals && + points.map((point) => { + const predicted = residuals.predict(point.x); + if (!Number.isFinite(predicted)) return null; + const px = mathToPixelX(point.x, viewport, width); + return ( + + ); + })} + + {points.map((point) => { + const px = mathToPixelX(point.x, viewport, width); + const py = mathToPixelY(point.y, viewport, height); + if (!Number.isFinite(px) || !Number.isFinite(py)) return null; + return ( + // biome-ignore lint/a11y/useSemanticElements: SVG has no native button element; a focusable is the accessible way to make an SVG point interactive. + { + event.currentTarget.setPointerCapture(event.pointerId); + dragRowRef.current = point.row; + }} + onPointerMove={(event) => { + if (dragRowRef.current !== point.row) return; + const { x, y } = toMath(event.clientX, event.clientY); + if (Number.isFinite(x) && Number.isFinite(y)) onPointMove(point.row, x, y); + }} + onPointerUp={() => { + dragRowRef.current = null; + }} + onPointerCancel={() => { + dragRowRef.current = null; + }} + onKeyDown={(event) => handleKeyDown(event, point)} + > + {/* Invisible finger-sized hit target. */} + + + + ); + })} + + ); +} diff --git a/apps/web/components/plots/regression/DataRegressionTab.test.tsx b/apps/web/components/plots/regression/DataRegressionTab.test.tsx new file mode 100644 index 00000000..b05378fb --- /dev/null +++ b/apps/web/components/plots/regression/DataRegressionTab.test.tsx @@ -0,0 +1,91 @@ +/** + * Tests for the Data & Regression tab. + * + * Uses the REAL @nextcalc/math-engine fit implementation (workspace source + * exports); only Plot2D is mocked because jsdom cannot initialize WebGL. + * The global vitest.setup next-intl mock returns raw message keys, so + * assertions target keys (e.g. 'status.converged'), while useFormatter + * numbers format normally ('1.0000'). + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { DataRegressionTab } from './DataRegressionTab'; + +vi.mock('@/components/plots/Plot2D', () => ({ + Plot2D: () => , +})); + +/** Fires a spreadsheet-style paste event on the data table. */ +function pasteIntoTable(payload: string) { + const table = screen.getByRole('table', { name: 'dataTitle' }); + fireEvent.paste(table, { + clipboardData: { getData: () => payload }, + }); +} + +describe('DataRegressionTab', () => { + it('populates cells from a pasted TSV block', () => { + render(); + pasteIntoTable('1\t2\n2\t4\n3\t6'); + + expect(screen.getByDisplayValue('1')).toBeInTheDocument(); + expect(screen.getByDisplayValue('4')).toBeInTheDocument(); + expect(screen.getByDisplayValue('6')).toBeInTheDocument(); + }); + + it('fills the model input from a canned chip', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'canned.linear' })); + + expect(screen.getByLabelText('modelLabel')).toHaveValue('y1 ~ m*x1 + b'); + }); + + it('shows R² 1.0000 and a converged badge for perfect linear data', async () => { + render(); + pasteIntoTable('1\t3\n2\t5\n3\t7\n4\t9'); + fireEvent.click(screen.getByRole('button', { name: 'canned.linear' })); + + // The fit trails by a deferred frame; findBy* flushes it inside act. + expect(await screen.findByText('status.converged')).toBeInTheDocument(); + expect(await screen.findByText('1.0000')).toBeInTheDocument(); + expect(screen.getByText('stats.title')).toBeInTheDocument(); + }); + + it('shows a translated failure and no stats for an invalid model', async () => { + render(); + pasteIntoTable('1\t3\n2\t5\n3\t7'); + fireEvent.change(screen.getByLabelText('modelLabel'), { + target: { value: 'y1 a*x1' }, + }); + + expect(await screen.findByText('status.invalidModel')).toBeInTheDocument(); + expect(screen.queryByText('stats.title')).not.toBeInTheDocument(); + }); + + it('flags an invalid column identifier with aria-invalid', () => { + render(); + const headers = screen.getAllByLabelText('columnName'); + const first = headers[0]; + if (!first) throw new Error('column header input not found'); + + fireEvent.change(first, { target: { value: '1abc' } }); + + expect(screen.getAllByLabelText('columnName')[0]).toHaveAttribute('aria-invalid', 'true'); + expect(screen.getByText('invalidColumnName')).toBeInTheDocument(); + }); + + it('reports an honest failure for degenerate data (all x identical)', async () => { + render(); + pasteIntoTable('5\t1\n5\t2\n5\t3\n5\t4'); + fireEvent.click(screen.getByRole('button', { name: 'canned.linear' })); + + // Either an explicit failure alert or a converged-but-poor fit is + // acceptable mathematically — but NEVER a silent perfect fit. + const failure = await screen + .findByText('status.singular') + .catch(() => screen.findByText('stats.title')); + expect(failure).toBeInTheDocument(); + expect(screen.queryByText('1.0000')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/plots/regression/DataRegressionTab.tsx b/apps/web/components/plots/regression/DataRegressionTab.tsx new file mode 100644 index 00000000..5e528301 --- /dev/null +++ b/apps/web/components/plots/regression/DataRegressionTab.tsx @@ -0,0 +1,273 @@ +'use client'; + +/** + * Data & Regression tab: columnar data table, tilde-model regression input, + * fitted-curve plot with draggable data points, and fit statistics. + * + * Derived values (parse, fit, plot config) are computed in render — the React + * Compiler memoizes them; `useDeferredValue` keeps point-dragging responsive + * while the Levenberg-Marquardt refit trails by one deferred frame. + * + * @module components/plots/regression/DataRegressionTab + */ + +import { type FitResult, fitModel, parseTildeModel } from '@nextcalc/math-engine/stats'; +import type { Plot2DCartesianConfig } from '@nextcalc/plot-engine'; +import { m } from 'motion/react'; +import { useFormatter, useTranslations } from 'next-intl'; +import { useDeferredValue, useState } from 'react'; +import { Plot2D } from '../Plot2D'; +import { PlotContainer } from '../PlotContainer'; +import { DataPointsOverlay, type OverlayPoint } from './DataPointsOverlay'; +import { DataTable, type DataTableValue } from './DataTable'; +import { FitStatsPanel } from './FitStatsPanel'; +import { RegressionModelInput } from './RegressionModelInput'; + +const POINT_COLOR = '#0891b2'; +const CURVE_COLOR = '#7c3aed'; +const DEFAULT_VIEWPORT = { xMin: -10, xMax: 10, yMin: -10, yMax: 10 }; + +/** Rounds a grid step to a 1/2/5 × 10ⁿ "nice" value (~8 major lines per axis). */ +function niceStep(range: number): number { + if (!Number.isFinite(range) || range <= 0) return 1; + const raw = range / 8; + const magnitude = 10 ** Math.floor(Math.log10(raw)); + const normalized = raw / magnitude; + const nice = normalized < 1.5 ? 1 : normalized < 3.5 ? 2 : normalized < 7.5 ? 5 : 10; + return nice * magnitude; +} + +function formatTick(value: number): string { + if (value === 0) return '0'; + const abs = Math.abs(value); + if (abs >= 10000 || abs < 0.001) return value.toExponential(1); + return Number(value.toPrecision(4)).toString(); +} + +/** Extracts finite (x, y) pairs from the first two table columns. */ +function extractPoints(rows: readonly (number | null)[][]): OverlayPoint[] { + const points: OverlayPoint[] = []; + rows.forEach((row, index) => { + const x = row[0]; + const y = row[1]; + if ( + typeof x === 'number' && + Number.isFinite(x) && + typeof y === 'number' && + Number.isFinite(y) + ) { + points.push({ x, y, row: index }); + } + }); + return points; +} + +/** Columnar record for fitModel; rows that are entirely empty are skipped. */ +function buildDataRecord( + columns: readonly string[], + rows: readonly (number | null)[][], +): Record { + const nonEmpty = rows.filter((row) => row.some((cell) => cell !== null)); + const record: Record = {}; + columns.forEach((name, ci) => { + record[name] = nonEmpty.map((row) => row[ci] ?? Number.NaN); + }); + return record; +} + +export function DataRegressionTab() { + const t = useTranslations('plots.regression'); + const format = useFormatter(); + + const [columns, setColumns] = useState(['x1', 'y1']); + const [rows, setRows] = useState<(number | null)[][]>([ + [null, null], + [null, null], + [null, null], + [null, null], + ]); + const [model, setModel] = useState(''); + const [showResiduals, setShowResiduals] = useState(false); + const [viewport, setViewport] = useState(DEFAULT_VIEWPORT); + + // ---- Derived in render (React Compiler memoizes; no manual memo hooks) ---- + + // Live parse feedback for the model input. + const parsed = model.trim() !== '' ? parseTildeModel(model, columns) : null; + const parseState = + parsed === null + ? null + : parsed.ok + ? ({ ok: true, parameters: parsed.parameters } as const) + : ({ ok: false, message: parsed.message } as const); + + // The expensive LM refit trails the latest keystroke/drag by a deferred frame. + const deferredColumns = useDeferredValue(columns); + const deferredRows = useDeferredValue(rows); + const deferredModel = useDeferredValue(model); + + const fit: FitResult | null = + deferredModel.trim() !== '' + ? fitModel(buildDataRecord(deferredColumns, deferredRows), deferredModel) + : null; + + // Fitted curve — only plottable for single-regressor models. + const regressor = + fit?.ok && fit.model.regressors.length === 1 ? fit.model.regressors[0] : undefined; + const curveFn = + fit?.ok && regressor !== undefined + ? (x: number): number => fit.predict({ [regressor]: x }) + : null; + + const points = extractPoints(rows); + + const config: Plot2DCartesianConfig = { + type: '2d-cartesian', + functions: + fit?.ok && curveFn + ? [ + { + fn: curveFn, + label: `${fit.model.dependent} ~ ${fit.model.rhsText}`, + style: { line: { width: 2.5, color: CURVE_COLOR } }, + }, + ] + : [], + viewport, + xAxis: { + label: columns[0] ?? 'x', + min: viewport.xMin, + max: viewport.xMax, + scale: 'linear', + grid: { + enabled: true, + majorStep: niceStep(viewport.xMax - viewport.xMin), + color: '#e5e7eb', + opacity: 0.5, + }, + ticks: { enabled: true, format: formatTick }, + }, + yAxis: { + label: columns[1] ?? 'y', + min: viewport.yMin, + max: viewport.yMax, + scale: 'linear', + grid: { + enabled: true, + majorStep: niceStep(viewport.yMax - viewport.yMin), + color: '#e5e7eb', + opacity: 0.5, + }, + ticks: { enabled: true, format: formatTick }, + }, + title: t('plotTitle'), + legend: { enabled: Boolean(fit?.ok && curveFn), position: 'top-right' }, + }; + + // ---- Handlers ---- + + /** Table edits (typing, paste, add/remove) auto-fit the viewport to the data. */ + const handleTableChange = (next: DataTableValue) => { + setColumns(next.columns); + setRows(next.rows); + const nextPoints = extractPoints(next.rows); + if (nextPoints.length >= 2) { + const xs = nextPoints.map((p) => p.x); + const ys = nextPoints.map((p) => p.y); + const xMin = Math.min(...xs); + const xMax = Math.max(...xs); + const yMin = Math.min(...ys); + const yMax = Math.max(...ys); + const padX = (xMax - xMin) * 0.12 || 1; + const padY = (yMax - yMin) * 0.12 || 1; + setViewport({ + xMin: xMin - padX, + xMax: xMax + padX, + yMin: yMin - padY, + yMax: yMax + padY, + }); + } + }; + + /** Point drags update the table without re-fitting the viewport. */ + const handlePointMove = (row: number, x: number, y: number) => { + const rx = Number(x.toPrecision(6)); + const ry = Number(y.toPrecision(6)); + setRows((prev) => + prev.map((r, i) => (i === row ? r.map((c, ci) => (ci === 0 ? rx : ci === 1 ? ry : c)) : r)), + ); + }; + + const pointLabel = (x: number, y: number): string => + t('pointLabel', { + x: format.number(x, { maximumSignificantDigits: 4 }), + y: format.number(y, { maximumSignificantDigits: 4 }), + }); + + const residualPredict = showResiduals && curveFn ? { predict: curveFn } : undefined; + + return ( + + {/* Sidebar: data table + model input */} +
+
+
+
+
+

{t('dataTitle')}

+ +
+
+ +
+
+
+ +
+
+
+
+ + {/* Plot + fit statistics */} +
+ +
+ + +
+
+ + +
+ + ); +} diff --git a/apps/web/components/plots/regression/DataTable.tsx b/apps/web/components/plots/regression/DataTable.tsx new file mode 100644 index 00000000..b5fc7435 --- /dev/null +++ b/apps/web/components/plots/regression/DataTable.tsx @@ -0,0 +1,252 @@ +'use client'; + +/** + * Editable columnar data table for the Data & Regression tab. + * + * - Native
semantics (caption + column headers) for accessibility. + * - Spreadsheet clipboard paste: TSV (or ;/, separated) payloads fill the grid + * starting at the focused cell, growing rows/columns as needed. + * - Column headers are editable identifiers, validated live. + * - Fully controlled and free of plot-specific imports so worksheets can host + * it later. + * + * @module components/plots/regression/DataTable + */ + +import { Plus, X } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { type ClipboardEvent, type KeyboardEvent, useRef, useState } from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from '../../ui/button'; +import { Input } from '../../ui/input'; + +/** Valid column identifier (mirrors the math-engine tilde-model rule). */ +const IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/; + +export interface DataTableValue { + columns: string[]; + rows: (number | null)[][]; +} + +export interface DataTableProps { + columns: string[]; + rows: (number | null)[][]; + onChange: (next: DataTableValue) => void; + className?: string; +} + +/** Generates a fresh column name (x2, y2, x3, …) not colliding with existing ones. */ +function nextColumnName(existing: readonly string[], index: number): string { + const prefix = index % 2 === 0 ? 'x' : 'y'; + for (let n = Math.floor(index / 2) + 1; ; n++) { + const candidate = `${prefix}${n}`; + if (!existing.includes(candidate)) return candidate; + } +} + +/** Parses one clipboard cell to a number or null. */ +function parseCell(text: string): number | null { + const trimmed = text.trim(); + if (trimmed === '') return null; + const value = Number(trimmed); + return Number.isFinite(value) ? value : null; +} + +/** + * Parses a spreadsheet clipboard payload into a cell matrix. + * Cells are tab-separated; when the payload has no tabs at all, `;`/`,` + * separators are accepted as a fallback. + */ +function parseClipboardMatrix(text: string): (number | null)[][] { + const lines = text.split(/\r?\n/); + while (lines.length > 0 && lines[lines.length - 1]?.trim() === '') lines.pop(); + const hasTab = text.includes('\t'); + return lines.map((line) => (hasTab ? line.split('\t') : line.split(/[;,]/)).map(parseCell)); +} + +export function DataTable({ columns, rows, onChange, className }: DataTableProps) { + const t = useTranslations('plots.regression'); + const tableRef = useRef(null); + // Local text for the cell currently being edited, so intermediate states + // like "3." or "-" don't get normalized away mid-keystroke. + const [editing, setEditing] = useState<{ row: number; col: number; text: string } | null>(null); + + const columnInvalid = (index: number): boolean => { + const name = columns[index] ?? ''; + return !IDENTIFIER_PATTERN.test(name) || columns.indexOf(name) !== index; + }; + + const commitCell = (row: number, col: number, value: number | null) => { + const nextRows = rows.map((r, ri) => + ri === row ? r.map((c, ci) => (ci === col ? value : c)) : r, + ); + onChange({ columns, rows: nextRows }); + }; + + const renameColumn = (index: number, name: string) => { + onChange({ columns: columns.map((c, i) => (i === index ? name : c)), rows }); + }; + + const addRow = () => { + onChange({ columns, rows: [...rows, columns.map(() => null)] }); + }; + + const addColumn = () => { + const name = nextColumnName(columns, columns.length); + onChange({ columns: [...columns, name], rows: rows.map((r) => [...r, null]) }); + }; + + const removeRow = (index: number) => { + onChange({ columns, rows: rows.filter((_, i) => i !== index) }); + }; + + const focusCell = (row: number, col: number) => { + requestAnimationFrame(() => { + tableRef.current + ?.querySelector(`input[data-row="${row}"][data-col="${col}"]`) + ?.focus(); + }); + }; + + const handlePaste = (event: ClipboardEvent) => { + const text = event.clipboardData.getData('text/plain'); + if (!text.includes('\t') && !text.includes('\n')) return; // single value: default input behavior + event.preventDefault(); + + const matrix = parseClipboardMatrix(text); + if (matrix.length === 0) return; + + // Paste anchor: the focused cell, or the top-left corner. + const active = document.activeElement; + const startRow = active instanceof HTMLInputElement ? Number(active.dataset['row'] ?? 0) : 0; + const startCol = active instanceof HTMLInputElement ? Number(active.dataset['col'] ?? 0) : 0; + + const neededCols = startCol + Math.max(...matrix.map((r) => r.length)); + const nextColumns = [...columns]; + while (nextColumns.length < neededCols) { + nextColumns.push(nextColumnName(nextColumns, nextColumns.length)); + } + + const neededRows = startRow + matrix.length; + const nextRows = rows.map((r) => { + const padded = [...r]; + while (padded.length < nextColumns.length) padded.push(null); + return padded; + }); + while (nextRows.length < neededRows) { + nextRows.push(nextColumns.map(() => null)); + } + + matrix.forEach((matrixRow, ri) => { + matrixRow.forEach((value, ci) => { + const targetRow = nextRows[startRow + ri]; + if (targetRow) targetRow[startCol + ci] = value; + }); + }); + + setEditing(null); + onChange({ columns: nextColumns, rows: nextRows }); + }; + + const handleCellKeyDown = (event: KeyboardEvent, row: number, col: number) => { + if (event.key !== 'Enter') return; + event.preventDefault(); + setEditing(null); + if (row === rows.length - 1) { + onChange({ columns, rows: [...rows, columns.map(() => null)] }); + } + focusCell(row + 1, col); + }; + + return ( +
+
+
+ + + + {columns.map((name, ci) => ( + + ))} + + + + + {rows.map((row, ri) => ( + + {columns.map((_, ci) => { + const isEditing = editing?.row === ri && editing.col === ci; + const stored = row[ci]; + const display = isEditing ? editing.text : stored == null ? '' : String(stored); + return ( + + ); + })} + + + ))} + +
{t('dataTitle')}
+ renameColumn(ci, e.target.value)} + aria-label={t('columnName')} + aria-invalid={columnInvalid(ci)} + required + pattern="[A-Za-z][A-Za-z0-9_]*" + className={cn( + 'h-8 w-auto min-w-16 field-sizing-content text-sm font-semibold font-mono', + 'user-invalid:border-red-500 aria-invalid:border-red-500', + )} + /> + + {t('removeRow')} +
+ { + const text = e.target.value; + setEditing({ row: ri, col: ci, text }); + commitCell(ri, ci, parseCell(text)); + }} + onBlur={() => setEditing(null)} + onKeyDown={(e) => handleCellKeyDown(e, ri, ci)} + className="h-8 w-auto min-w-16 field-sizing-content text-sm font-mono tabular-nums" + /> + + +
+ + +
+ + +
+ + {columns.some((_, i) => columnInvalid(i)) && ( +

+ {t('invalidColumnName')} +

+ )} + +

{t('pasteHint')}

+ + ); +} diff --git a/apps/web/components/plots/regression/FitStatsPanel.tsx b/apps/web/components/plots/regression/FitStatsPanel.tsx new file mode 100644 index 00000000..eac6207b --- /dev/null +++ b/apps/web/components/plots/regression/FitStatsPanel.tsx @@ -0,0 +1,147 @@ +'use client'; + +/** + * Fit statistics panel: fitted parameters, R², RMSE, iterations, convergence + * status and warnings — plus the residual-segment toggle. + * + * Dataviz fundamentals: one panel, one axis of truth, every numeral + * tabular-nums. A non-converged or failed fit is ALWAYS visibly flagged — + * never a silent bad fit. + * + * @module components/plots/regression/FitStatsPanel + */ + +import type { FitResult, FitWarning } from '@nextcalc/math-engine/stats'; +import { AlertTriangle } from 'lucide-react'; +import { useFormatter, useTranslations } from 'next-intl'; +import { useId } from 'react'; +import { Badge } from '../../ui/badge'; +import { Label } from '../../ui/label'; +import { Switch } from '../../ui/switch'; + +export interface FitStatsPanelProps { + fit: FitResult | null; + showResiduals: boolean; + onToggleResiduals: (value: boolean) => void; +} + +/** Maps a fit failure status to its translation key. */ +const FAILURE_KEYS = { + 'invalid-model': 'status.invalidModel', + 'insufficient-data': 'status.insufficientData', + singular: 'status.singular', + diverged: 'status.diverged', +} as const; + +export function FitStatsPanel({ fit, showResiduals, onToggleResiduals }: FitStatsPanelProps) { + const t = useTranslations('plots.regression'); + const format = useFormatter(); + const toggleId = useId(); + + if (fit === null) return null; + + const warningText = (warning: FitWarning): string => { + switch (warning.code) { + case 'dropped-rows': + return t('warnings.droppedRows', { count: warning.count }); + case 'bound-hit': + return t('warnings.boundHit', { name: warning.name }); + case 'zero-variance': + return t('warnings.zeroVariance'); + } + }; + + if (!fit.ok) { + return ( +
+

+

+

{fit.message}

+
+ ); + } + + const number = (value: number) => format.number(value, { maximumSignificantDigits: 6 }); + + return ( +
+
+

{t('stats.title')}

+ {fit.status === 'converged' ? ( + + {t('status.converged')} + + ) : ( + + {t('status.maxIterations')} + + )} +
+ + + + + {Object.entries(fit.parameters).map(([name, value]) => ( + + + + + ))} + +
{t('stats.parameters')}
+ {name} + + {number(value)} + {fit.standardErrors?.[name] !== undefined && ( + + {' '} + ± {format.number(fit.standardErrors[name], { maximumSignificantDigits: 3 })} + + )} +
+ +
+
+
{t('stats.r2')}
+
+ {format.number(fit.r2, { minimumFractionDigits: 4, maximumFractionDigits: 4 })} +
+
+
+
{t('stats.rmse')}
+
{number(fit.rmse)}
+
+
+
{t('stats.iterations')}
+
{fit.iterations}
+
+
+ + {fit.warnings.length > 0 && ( +
    + {fit.warnings.map((warning) => ( +
  • +
  • + ))} +
+ )} + +
+ + +
+
+ ); +} diff --git a/apps/web/components/plots/regression/RegressionModelInput.tsx b/apps/web/components/plots/regression/RegressionModelInput.tsx new file mode 100644 index 00000000..aefaef45 --- /dev/null +++ b/apps/web/components/plots/regression/RegressionModelInput.tsx @@ -0,0 +1,108 @@ +'use client'; + +/** + * Tilde-syntax regression model input with canned-model chips. + * + * Deliberately NOT built on FunctionInput: its expression validator rejects + * the `~` separator. Validation feedback mirrors FunctionInput's visual + * language (CheckCircle2 / AlertCircle + text, aria-live). + * + * @module components/plots/regression/RegressionModelInput + */ + +import { buildCannedModel, type CannedModelKind } from '@nextcalc/math-engine/stats'; +import { AlertCircle, CheckCircle2 } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useId } from 'react'; +import { Input } from '../../ui/input'; +import { Label } from '../../ui/label'; + +const CANNED_KINDS: readonly CannedModelKind[] = [ + 'linear', + 'quadratic', + 'exponential', + 'logarithmic', + 'logistic', + 'sinusoidal', +]; + +export type ModelParseState = + | { ok: true; parameters: readonly string[] } + | { ok: false; message: string }; + +export interface RegressionModelInputProps { + model: string; + onModelChange: (value: string) => void; + /** First (x) and second (y) data columns used by the canned-model chips. */ + firstX: string; + firstY: string; + /** Live parse feedback; null when the input is empty. */ + parseState: ModelParseState | null; +} + +export function RegressionModelInput({ + model, + onModelChange, + firstX, + firstY, + parseState, +}: RegressionModelInputProps) { + const t = useTranslations('plots.regression'); + const inputId = useId(); + + return ( +
+
+ + onModelChange(e.target.value)} + placeholder={t('modelPlaceholder')} + className={`h-9 text-sm font-mono ${ + model.trim() && parseState && !parseState.ok + ? 'border-red-500 focus:border-red-500' + : '' + }`} + /> + {/* Live validation feedback (same visual language as FunctionInput). */} +
+ {model.trim() !== '' && parseState?.ok === true && ( + <> +
+
+ +
+

+ {t('cannedTitle')} +

+
+ {CANNED_KINDS.map((kind) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 605d49ee..6cb409eb 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1055,7 +1055,54 @@ "2dCartesian": "2D Cartesian", "2dPolar": "2D Polar", "2dParametric": "2D Parametric", - "3dSurface": "3D Surface" + "3dSurface": "3D Surface", + "dataRegression": "Daten & Regression" + }, + "regression": { + "title": "Daten & Regression", + "dataTitle": "Datentabelle", + "pasteHint": "Fügen Sie Daten aus einer Tabellenkalkulation (tabulatorgetrennt) direkt in die Tabelle ein.", + "addColumn": "Spalte hinzufügen", + "addRow": "Zeile hinzufügen", + "removeRow": "Zeile entfernen", + "columnName": "Spaltenname", + "invalidColumnName": "Spaltennamen müssen mit einem Buchstaben beginnen und eindeutig sein", + "modelLabel": "Regressionsmodell", + "modelPlaceholder": "z. B. y1 ~ a*exp(b*x1)", + "modelValid": "Parameter: {parameters}", + "cannedTitle": "Modellvorlagen", + "canned": { + "linear": "Linear", + "quadratic": "Quadratisch", + "exponential": "Exponentiell", + "logarithmic": "Logarithmisch", + "logistic": "Logistisch", + "sinusoidal": "Sinusförmig" + }, + "stats": { + "title": "Anpassungsstatistik", + "parameters": "Angepasste Parameter", + "r2": "R²", + "rmse": "RMSE", + "iterations": "Iterationen" + }, + "status": { + "converged": "Konvergiert", + "maxIterations": "Nicht konvergiert (Iterationslimit erreicht) – Anpassung mit Vorsicht interpretieren", + "invalidModel": "Ungültiges Modell", + "insufficientData": "Nicht genügend Datenpunkte für die Anzahl der Parameter", + "singular": "Die Parameter lassen sich aus diesen Daten nicht bestimmen", + "diverged": "Das Modell kann mit diesen Daten nicht ausgewertet werden" + }, + "residualsToggle": "Residuen anzeigen", + "dragHint": "Punkte ziehen, um sie zu verschieben – die Anpassung aktualisiert sich sofort. Hintergrund ziehen zum Verschieben, Scrollen zum Zoomen.", + "pointLabel": "Datenpunkt ({x}, {y})", + "plotTitle": "Daten & angepasste Kurve", + "warnings": { + "droppedRows": "{count} Zeilen mit fehlenden oder nicht numerischen Werten ignoriert", + "boundHit": "Parameter {name} hat seine Schranke erreicht", + "zeroVariance": "Die abhängige Spalte ist konstant – R² ist nicht aussagekräftig" + } } }, "chaos": { diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a665df56..6aa88f74 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1089,7 +1089,54 @@ "2dCartesian": "2D Cartesian", "2dPolar": "2D Polar", "2dParametric": "2D Parametric", - "3dSurface": "3D Surface" + "3dSurface": "3D Surface", + "dataRegression": "Data & Regression" + }, + "regression": { + "title": "Data & Regression", + "dataTitle": "Data table", + "pasteHint": "Paste data from a spreadsheet (tab-separated) directly into the table.", + "addColumn": "Add column", + "addRow": "Add row", + "removeRow": "Remove row", + "columnName": "Column name", + "invalidColumnName": "Column names must start with a letter and be unique", + "modelLabel": "Regression model", + "modelPlaceholder": "e.g. y1 ~ a*exp(b*x1)", + "modelValid": "Parameters: {parameters}", + "cannedTitle": "Model templates", + "canned": { + "linear": "Linear", + "quadratic": "Quadratic", + "exponential": "Exponential", + "logarithmic": "Logarithmic", + "logistic": "Logistic", + "sinusoidal": "Sinusoidal" + }, + "stats": { + "title": "Fit statistics", + "parameters": "Fitted parameters", + "r2": "R²", + "rmse": "RMSE", + "iterations": "Iterations" + }, + "status": { + "converged": "Converged", + "maxIterations": "Did not converge (iteration limit reached) — treat the fit with caution", + "invalidModel": "Invalid model", + "insufficientData": "Not enough data points for the number of parameters", + "singular": "Parameters cannot be identified from this data", + "diverged": "The model cannot be evaluated on this data" + }, + "residualsToggle": "Show residuals", + "dragHint": "Drag points to move them; the fit updates live. Drag the background to pan, scroll to zoom.", + "pointLabel": "Data point ({x}, {y})", + "plotTitle": "Data & fitted curve", + "warnings": { + "droppedRows": "Ignored {count} rows with missing or non-numeric values", + "boundHit": "Parameter {name} hit its bound", + "zeroVariance": "The dependent column is constant — R² is not meaningful" + } } }, "chaos": { diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 9bad412f..b5126a88 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -1055,7 +1055,54 @@ "2dCartesian": "2D Cartesiano", "2dPolar": "2D Polar", "2dParametric": "2D Paramétrico", - "3dSurface": "Superficie 3D" + "3dSurface": "Superficie 3D", + "dataRegression": "Datos y regresión" + }, + "regression": { + "title": "Datos y regresión", + "dataTitle": "Tabla de datos", + "pasteHint": "Pega datos de una hoja de cálculo (separados por tabulaciones) directamente en la tabla.", + "addColumn": "Añadir columna", + "addRow": "Añadir fila", + "removeRow": "Eliminar fila", + "columnName": "Nombre de columna", + "invalidColumnName": "Los nombres de columna deben empezar por una letra y ser únicos", + "modelLabel": "Modelo de regresión", + "modelPlaceholder": "p. ej. y1 ~ a*exp(b*x1)", + "modelValid": "Parámetros: {parameters}", + "cannedTitle": "Plantillas de modelos", + "canned": { + "linear": "Lineal", + "quadratic": "Cuadrática", + "exponential": "Exponencial", + "logarithmic": "Logarítmica", + "logistic": "Logística", + "sinusoidal": "Sinusoidal" + }, + "stats": { + "title": "Estadísticas del ajuste", + "parameters": "Parámetros ajustados", + "r2": "R²", + "rmse": "RMSE", + "iterations": "Iteraciones" + }, + "status": { + "converged": "Convergió", + "maxIterations": "No convergió (límite de iteraciones alcanzado): interprete el ajuste con cautela", + "invalidModel": "Modelo no válido", + "insufficientData": "No hay suficientes puntos de datos para el número de parámetros", + "singular": "Los parámetros no pueden identificarse a partir de estos datos", + "diverged": "El modelo no puede evaluarse con estos datos" + }, + "residualsToggle": "Mostrar residuos", + "dragHint": "Arrastra los puntos para moverlos; el ajuste se actualiza al instante. Arrastra el fondo para desplazarte y usa la rueda para hacer zoom.", + "pointLabel": "Punto de datos ({x}, {y})", + "plotTitle": "Datos y curva ajustada", + "warnings": { + "droppedRows": "Se ignoraron {count} filas con valores ausentes o no numéricos", + "boundHit": "El parámetro {name} alcanzó su límite", + "zeroVariance": "La columna dependiente es constante: R² no es significativo" + } } }, "chaos": { diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 07f95f6f..1f8ce2d3 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1088,7 +1088,54 @@ "2dCartesian": "2D Cartésien", "2dPolar": "2D Polaire", "2dParametric": "2D Paramétrique", - "3dSurface": "Surface 3D" + "3dSurface": "Surface 3D", + "dataRegression": "Données et régression" + }, + "regression": { + "title": "Données et régression", + "dataTitle": "Tableau de données", + "pasteHint": "Collez des données d'un tableur (séparées par des tabulations) directement dans le tableau.", + "addColumn": "Ajouter une colonne", + "addRow": "Ajouter une ligne", + "removeRow": "Supprimer la ligne", + "columnName": "Nom de colonne", + "invalidColumnName": "Les noms de colonnes doivent commencer par une lettre et être uniques", + "modelLabel": "Modèle de régression", + "modelPlaceholder": "p. ex. y1 ~ a*exp(b*x1)", + "modelValid": "Paramètres : {parameters}", + "cannedTitle": "Modèles prédéfinis", + "canned": { + "linear": "Linéaire", + "quadratic": "Quadratique", + "exponential": "Exponentielle", + "logarithmic": "Logarithmique", + "logistic": "Logistique", + "sinusoidal": "Sinusoïdale" + }, + "stats": { + "title": "Statistiques d'ajustement", + "parameters": "Paramètres ajustés", + "r2": "R²", + "rmse": "RMSE", + "iterations": "Itérations" + }, + "status": { + "converged": "Convergence atteinte", + "maxIterations": "Pas de convergence (limite d'itérations atteinte) — interprétez l'ajustement avec prudence", + "invalidModel": "Modèle non valide", + "insufficientData": "Pas assez de points de données pour le nombre de paramètres", + "singular": "Les paramètres ne peuvent pas être identifiés à partir de ces données", + "diverged": "Le modèle ne peut pas être évalué sur ces données" + }, + "residualsToggle": "Afficher les résidus", + "dragHint": "Faites glisser les points pour les déplacer ; l'ajustement se met à jour instantanément. Faites glisser l'arrière-plan pour vous déplacer, molette pour zoomer.", + "pointLabel": "Point de données ({x}, {y})", + "plotTitle": "Données et courbe ajustée", + "warnings": { + "droppedRows": "{count} lignes avec des valeurs manquantes ou non numériques ont été ignorées", + "boundHit": "Le paramètre {name} a atteint sa borne", + "zeroVariance": "La colonne dépendante est constante — R² n'est pas significatif" + } } }, "chaos": { diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 2420ac24..f65a6bf1 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -1088,7 +1088,54 @@ "2dCartesian": "2D デカルト座標", "2dPolar": "2D 極座標", "2dParametric": "2D パラメトリック", - "3dSurface": "3D 曲面" + "3dSurface": "3D 曲面", + "dataRegression": "データと回帰" + }, + "regression": { + "title": "データと回帰", + "dataTitle": "データテーブル", + "pasteHint": "スプレッドシートのデータ(タブ区切り)をそのまま表に貼り付けられます。", + "addColumn": "列を追加", + "addRow": "行を追加", + "removeRow": "行を削除", + "columnName": "列名", + "invalidColumnName": "列名は英字で始まり、重複しないようにしてください", + "modelLabel": "回帰モデル", + "modelPlaceholder": "例: y1 ~ a*exp(b*x1)", + "modelValid": "パラメータ: {parameters}", + "cannedTitle": "モデルテンプレート", + "canned": { + "linear": "線形", + "quadratic": "二次", + "exponential": "指数", + "logarithmic": "対数", + "logistic": "ロジスティック", + "sinusoidal": "正弦波" + }, + "stats": { + "title": "フィット統計", + "parameters": "推定パラメータ", + "r2": "R²", + "rmse": "RMSE", + "iterations": "反復回数" + }, + "status": { + "converged": "収束", + "maxIterations": "収束しませんでした(反復上限に到達)— フィット結果は慎重に扱ってください", + "invalidModel": "無効なモデル", + "insufficientData": "パラメータ数に対してデータ点が不足しています", + "singular": "このデータからパラメータを特定できません", + "diverged": "このデータではモデルを評価できません" + }, + "residualsToggle": "残差を表示", + "dragHint": "点をドラッグすると移動でき、フィットは即座に更新されます。背景をドラッグでパン、スクロールでズーム。", + "pointLabel": "データ点 ({x}, {y})", + "plotTitle": "データとフィット曲線", + "warnings": { + "droppedRows": "欠損値または数値でない値を含む {count} 行を無視しました", + "boundHit": "パラメータ {name} が境界に達しました", + "zeroVariance": "従属変数の列が一定のため、R² は意味を持ちません" + } } }, "chaos": { diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index 3b5e1f2e..c949bcf2 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -1055,7 +1055,54 @@ "2dCartesian": "2D декартовы", "2dPolar": "2D полярные", "2dParametric": "2D параметрические", - "3dSurface": "3D поверхность" + "3dSurface": "3D поверхность", + "dataRegression": "Данные и регрессия" + }, + "regression": { + "title": "Данные и регрессия", + "dataTitle": "Таблица данных", + "pasteHint": "Вставьте данные из электронной таблицы (разделённые табуляцией) прямо в таблицу.", + "addColumn": "Добавить столбец", + "addRow": "Добавить строку", + "removeRow": "Удалить строку", + "columnName": "Имя столбца", + "invalidColumnName": "Имена столбцов должны начинаться с буквы и быть уникальными", + "modelLabel": "Модель регрессии", + "modelPlaceholder": "напр. y1 ~ a*exp(b*x1)", + "modelValid": "Параметры: {parameters}", + "cannedTitle": "Шаблоны моделей", + "canned": { + "linear": "Линейная", + "quadratic": "Квадратичная", + "exponential": "Экспоненциальная", + "logarithmic": "Логарифмическая", + "logistic": "Логистическая", + "sinusoidal": "Синусоидальная" + }, + "stats": { + "title": "Статистика подгонки", + "parameters": "Подобранные параметры", + "r2": "R²", + "rmse": "RMSE", + "iterations": "Итерации" + }, + "status": { + "converged": "Сошлась", + "maxIterations": "Не сошлась (достигнут предел итераций) — интерпретируйте подгонку с осторожностью", + "invalidModel": "Недопустимая модель", + "insufficientData": "Недостаточно точек данных для числа параметров", + "singular": "Параметры не определяются по этим данным", + "diverged": "Модель не вычисляется на этих данных" + }, + "residualsToggle": "Показать остатки", + "dragHint": "Перетаскивайте точки — подгонка обновляется сразу. Перетаскивайте фон для панорамирования, колесо — для масштаба.", + "pointLabel": "Точка данных ({x}, {y})", + "plotTitle": "Данные и подобранная кривая", + "warnings": { + "droppedRows": "Пропущено строк с отсутствующими или нечисловыми значениями: {count}", + "boundHit": "Параметр {name} достиг границы", + "zeroVariance": "Зависимый столбец постоянен — R² не информативен" + } } }, "chaos": { diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index 42bf1a9e..b1e79df5 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -1055,7 +1055,54 @@ "2dCartesian": "2D Cartesian", "2dPolar": "2D Polar", "2dParametric": "2D Parametric", - "3dSurface": "3D Surface" + "3dSurface": "3D Surface", + "dataRegression": "Дані та регресія" + }, + "regression": { + "title": "Дані та регресія", + "dataTitle": "Таблиця даних", + "pasteHint": "Вставте дані з електронної таблиці (розділені табуляцією) безпосередньо в таблицю.", + "addColumn": "Додати стовпець", + "addRow": "Додати рядок", + "removeRow": "Видалити рядок", + "columnName": "Назва стовпця", + "invalidColumnName": "Назви стовпців мають починатися з літери та бути унікальними", + "modelLabel": "Модель регресії", + "modelPlaceholder": "напр. y1 ~ a*exp(b*x1)", + "modelValid": "Параметри: {parameters}", + "cannedTitle": "Шаблони моделей", + "canned": { + "linear": "Лінійна", + "quadratic": "Квадратична", + "exponential": "Експоненційна", + "logarithmic": "Логарифмічна", + "logistic": "Логістична", + "sinusoidal": "Синусоїдальна" + }, + "stats": { + "title": "Статистика підгонки", + "parameters": "Підібрані параметри", + "r2": "R²", + "rmse": "RMSE", + "iterations": "Ітерації" + }, + "status": { + "converged": "Зійшлася", + "maxIterations": "Не зійшлася (досягнуто ліміт ітерацій) — інтерпретуйте підгонку з обережністю", + "invalidModel": "Неприпустима модель", + "insufficientData": "Недостатньо точок даних для кількості параметрів", + "singular": "Параметри неможливо визначити з цих даних", + "diverged": "Модель не обчислюється на цих даних" + }, + "residualsToggle": "Показати залишки", + "dragHint": "Перетягуйте точки — підгонка оновлюється одразу. Перетягуйте фон для панорамування, коліщатко — для масштабу.", + "pointLabel": "Точка даних ({x}, {y})", + "plotTitle": "Дані та підібрана крива", + "warnings": { + "droppedRows": "Пропущено рядків із відсутніми або нечисловими значеннями: {count}", + "boundHit": "Параметр {name} досяг межі", + "zeroVariance": "Залежний стовпець сталий — R² не інформативний" + } } }, "chaos": { diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 2531a3af..c01498e2 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -1088,7 +1088,54 @@ "2dCartesian": "2D 直角坐标", "2dPolar": "2D 极坐标", "2dParametric": "2D 参数化", - "3dSurface": "3D 曲面" + "3dSurface": "3D 曲面", + "dataRegression": "数据与回归" + }, + "regression": { + "title": "数据与回归", + "dataTitle": "数据表", + "pasteHint": "可将电子表格中的数据(制表符分隔)直接粘贴到表格中。", + "addColumn": "添加列", + "addRow": "添加行", + "removeRow": "删除行", + "columnName": "列名", + "invalidColumnName": "列名必须以字母开头且不能重复", + "modelLabel": "回归模型", + "modelPlaceholder": "例如 y1 ~ a*exp(b*x1)", + "modelValid": "参数:{parameters}", + "cannedTitle": "模型模板", + "canned": { + "linear": "线性", + "quadratic": "二次", + "exponential": "指数", + "logarithmic": "对数", + "logistic": "逻辑斯谛", + "sinusoidal": "正弦" + }, + "stats": { + "title": "拟合统计", + "parameters": "拟合参数", + "r2": "R²", + "rmse": "RMSE", + "iterations": "迭代次数" + }, + "status": { + "converged": "已收敛", + "maxIterations": "未收敛(达到迭代上限)— 请谨慎对待该拟合", + "invalidModel": "模型无效", + "insufficientData": "数据点数量不足以拟合该参数个数", + "singular": "无法从这些数据中确定参数", + "diverged": "无法在这些数据上求值该模型" + }, + "residualsToggle": "显示残差", + "dragHint": "拖动数据点即可移动,拟合会即时更新。拖动背景平移,滚轮缩放。", + "pointLabel": "数据点 ({x}, {y})", + "plotTitle": "数据与拟合曲线", + "warnings": { + "droppedRows": "已忽略 {count} 行缺失或非数值数据", + "boundHit": "参数 {name} 已达到边界", + "zeroVariance": "因变量列为常数 — R² 无意义" + } } }, "chaos": { From 4ab45340473ea42aa943e67ae2c338df75c31eaf Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:25:53 -0500 Subject: [PATCH 3/3] fix(regression): eight adversarial-review findings in tables + fitting - canned-model chips rename parameter letters that collide with column names (renaming a column to "b" + Linear chip previously produced a vacuous self-referential fit reported as converged, R^2=1) - parseTildeModel rejects column names shadowed by built-in constants (e/pi/tau/i/phi) instead of silently using the constant's value - duplicate column names now gate the fit with an honest invalid-model failure instead of fitting against silently-collapsed data - point drag anchors the grab offset so a sloppy click on the 12px hit target no longer snaps the point (and the data) to the cursor - comma-decimal locales: "," is a decimal separator on paste, never a field separator (";" remains the non-TSV field fallback) - ragged columns emit the promised dropped-rows warning instead of silent truncation to the shortest column - tiny-magnitude regressors (e.g. SI meters) no longer misreport as "singular": Marquardt column-scaled fallback solve when the raw normal equations trip the absolute pivot threshold - draggable points' interactive markup is now actually exercised in tests: local firing ResizeObserver stub (the global mock is a no-op that left the overlay in its empty early-return branch), dedicated DataPointsOverlay test file with axe coverage Co-Authored-By: Claude Fable 5 --- .../data-regression.a11y.test.tsx | 82 ++++++-- .../regression/DataPointsOverlay.test.tsx | 172 +++++++++++++++++ .../plots/regression/DataPointsOverlay.tsx | 25 ++- .../regression/DataRegressionTab.test.tsx | 33 ++++ .../plots/regression/DataRegressionTab.tsx | 21 ++- .../components/plots/regression/DataTable.tsx | 28 ++- packages/math-engine/src/stats/fit.test.ts | 55 ++++++ packages/math-engine/src/stats/fit.ts | 178 ++++++++++++++++-- 8 files changed, 549 insertions(+), 45 deletions(-) create mode 100644 apps/web/components/plots/regression/DataPointsOverlay.test.tsx diff --git a/apps/web/__tests__/accessibility/data-regression.a11y.test.tsx b/apps/web/__tests__/accessibility/data-regression.a11y.test.tsx index 26a90ee9..fba4a718 100644 --- a/apps/web/__tests__/accessibility/data-regression.a11y.test.tsx +++ b/apps/web/__tests__/accessibility/data-regression.a11y.test.tsx @@ -3,17 +3,48 @@ * * Guard: native semantics with caption/headers, labelled inputs and * an accessibly-named residual toggle — verified with axe, not just biome. + * + * The global vitest.setup ResizeObserver mock is a no-op, so DataPointsOverlay + * (rendered directly inside this tab) never leaves its {width:0,height:0} + * early-return branch under jsdom — the accessible, keyboard-operable + * draggable points (``) would + * never actually be mounted, so no test in this suite would ever notice a + * regression to that markup. The "fitted state" test below stubs a firing + * ResizeObserver so the points actually mount and asserts on them directly + * (role/tabindex/accessible name); full axe coverage of that exact markup + * lives in DataPointsOverlay.test.tsx (see the note on that test for why + * axe() isn't also run here). */ import { fireEvent, render, screen } from '@testing-library/react'; import { axe } from 'jest-axe'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DataRegressionTab } from '@/components/plots'; vi.mock('@/components/plots/Plot2D', () => ({ Plot2D: () => , })); +/** A ResizeObserver stub that immediately fires its callback with a fixed, + * non-zero content rect — unlike the global no-op mock in vitest.setup.ts. */ +class FiringResizeObserver { + #callback: ResizeObserverCallback; + + constructor(callback: ResizeObserverCallback) { + this.#callback = callback; + } + + observe(target: Element) { + this.#callback( + [{ target, contentRect: { width: 600, height: 400 } } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + } + + unobserve() {} + disconnect() {} +} + describe('Data & Regression accessibility (WCAG 2.2)', () => { it('empty tab has no axe violations', async () => { const { container } = render(); @@ -21,21 +52,48 @@ describe('Data & Regression accessibility (WCAG 2.2)', () => { expect(results).toHaveNoViolations(); }); - it('fitted state (stats panel + toggle) has no axe violations', async () => { - const { container } = render(); + describe('with draggable data points mounted', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', FiringResizeObserver); + }); - const table = screen.getByRole('table', { name: 'dataTitle' }); - fireEvent.paste(table, { - clipboardData: { getData: () => '1\t3\n2\t5\n3\t7\n4\t9' }, + afterEach(() => { + vi.unstubAllGlobals(); }); - fireEvent.click(screen.getByRole('button', { name: 'canned.linear' })); - await screen.findByText('status.converged'); - // Residual toggle is a labelled switch. - expect(screen.getByRole('switch', { name: 'residualsToggle' })).toBeInTheDocument(); + it('fitted state mounts keyboard-operable draggable points with accessible names', async () => { + render(); - const results = await axe(container); - expect(results).toHaveNoViolations(); + const table = screen.getByRole('table', { name: 'dataTitle' }); + fireEvent.paste(table, { + clipboardData: { getData: () => '1\t3\n2\t5\n3\t7\n4\t9' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'canned.linear' })); + await screen.findByText('status.converged'); + + // Residual toggle is a labelled switch. + expect(screen.getByRole('switch', { name: 'residualsToggle' })).toBeInTheDocument(); + + // The draggable data points actually mounted (regression guard for the + // ResizeObserver-mock blind spot described above) and are keyboard + // operable. (The global next-intl mock returns raw `key:{"args":...}` + // strings, not the formatted `pointLabel` sentence.) + const points = screen.getAllByRole('button', { name: /^pointLabel:/ }); + expect(points.length).toBeGreaterThan(0); + for (const point of points) { + expect(point).toHaveAttribute('tabindex', '0'); + expect(point).toHaveAccessibleName(); + } + + // NOTE: axe() is intentionally not run in this scenario. The global + // next-intl mock renders interpolated translations as literal + // `key:{"x":"1",...}` strings, and axe-core's selector generator + // crashes under happy-dom when an aria-label contains embedded double + // quotes (a test-environment interaction bug, not a real accessibility + // defect — real translated point labels never contain quote + // characters). The interactive markup itself IS exercised by axe in + // DataPointsOverlay.test.tsx, which supplies plain-text labels. + }); }); it('data table uses native semantics: caption and column headers', () => { diff --git a/apps/web/components/plots/regression/DataPointsOverlay.test.tsx b/apps/web/components/plots/regression/DataPointsOverlay.test.tsx new file mode 100644 index 00000000..4d15f5ab --- /dev/null +++ b/apps/web/components/plots/regression/DataPointsOverlay.test.tsx @@ -0,0 +1,172 @@ +/** + * Tests for the draggable/keyboard-operable data points overlay. + * + * The global vitest.setup ResizeObserver mock is a no-op (`observe(){}` + * never invokes its callback), so under jsdom/happy-dom the component's + * `size` state never leaves {0,0} and it permanently takes its early-return + * branch — a bare `
semantics (caption + column headers) for accessibility. - * - Spreadsheet clipboard paste: TSV (or ;/, separated) payloads fill the grid - * starting at the focused cell, growing rows/columns as needed. + * - Spreadsheet clipboard paste: TSV (or ;-separated) payloads fill the grid + * starting at the focused cell, growing rows/columns as needed. `,` is + * reserved as a decimal separator (de/fr/ru/uk locales), never a field + * separator. * - Column headers are editable identifiers, validated live. * - Fully controlled and free of plot-specific imports so worksheets can host * it later. @@ -44,24 +46,36 @@ function nextColumnName(existing: readonly string[], index: number): string { } } -/** Parses one clipboard cell to a number or null. */ +/** Matches a lone comma-decimal number, e.g. "1,5" or "-3,14" (de/fr/ru/uk locales). */ +const COMMA_DECIMAL_PATTERN = /^-?\d+,\d+$/; + +/** + * Parses one clipboard cell to a number or null. A bare comma decimal + * separator (no thousands grouping, no dot) is normalized to a dot first — + * Excel/LibreOffice in comma-decimal locales render 1.5 as "1,5". + */ function parseCell(text: string): number | null { const trimmed = text.trim(); if (trimmed === '') return null; - const value = Number(trimmed); + const normalized = COMMA_DECIMAL_PATTERN.test(trimmed) ? trimmed.replace(',', '.') : trimmed; + const value = Number(normalized); return Number.isFinite(value) ? value : null; } /** * Parses a spreadsheet clipboard payload into a cell matrix. - * Cells are tab-separated; when the payload has no tabs at all, `;`/`,` - * separators are accepted as a fallback. + * Cells are tab-separated; when the payload has no tabs at all, `;` is + * accepted as a fallback field separator. `,` is deliberately NOT treated as + * a field separator: comma-decimal locale spreadsheets (de/fr/ru/uk) use `;` + * as the field separator for exactly this reason, reserving `,` for decimal + * numbers (see parseCell) — otherwise a single-column paste like "1,5\n2,5" + * would be silently split into two bogus columns. */ function parseClipboardMatrix(text: string): (number | null)[][] { const lines = text.split(/\r?\n/); while (lines.length > 0 && lines[lines.length - 1]?.trim() === '') lines.pop(); const hasTab = text.includes('\t'); - return lines.map((line) => (hasTab ? line.split('\t') : line.split(/[;,]/)).map(parseCell)); + return lines.map((line) => (hasTab ? line.split('\t') : line.split(';')).map(parseCell)); } export function DataTable({ columns, rows, onChange, className }: DataTableProps) { diff --git a/packages/math-engine/src/stats/fit.test.ts b/packages/math-engine/src/stats/fit.test.ts index 6a9ff28f..4dd4810d 100644 --- a/packages/math-engine/src/stats/fit.test.ts +++ b/packages/math-engine/src/stats/fit.test.ts @@ -68,6 +68,14 @@ describe('parseTildeModel', () => { expect(parsed.parameters).toEqual(['a', 'b']); } }); + + it('rejects a model where a data column name collides with a built-in constant', () => { + // A column literally named "e" would otherwise be invisible to + // extractVariables (excluded as the constant Math.E) and silently never + // become a regressor — the column's actual data would never be used. + const parsed = parseTildeModel('y1 ~ m*e + b', ['e', 'y1']); + expect(parsed).toMatchObject({ ok: false, code: 'reserved-name-collision' }); + }); }); describe('buildCannedModel', () => { @@ -79,6 +87,33 @@ describe('buildCannedModel', () => { expect(buildCannedModel('logistic', 'x1', 'y1')).toBe('y1 ~ L/(1 + exp(-k*(x1 - x0)))'); expect(buildCannedModel('sinusoidal', 'x1', 'y1')).toBe('y1 ~ a*sin(b*x1 + c) + d'); }); + + it('renames a colliding parameter letter instead of reusing the column name', () => { + // The y-column renamed to 'b' collides with the linear template's + // hardcoded intercept letter — parseTildeModel would otherwise silently + // reclassify 'b' as a regressor bound to the dependent column itself. + const model = buildCannedModel('linear', 'x1', 'b'); + expect(model).not.toBe('b ~ m*x1 + b'); + const parsed = parseTildeModel(model, ['x1', 'b']); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.dependent).toBe('b'); + expect(parsed.regressors).toEqual(['x1']); + expect(parsed.parameters).not.toContain('b'); + } + }); + + it('renames a colliding regressor letter (quadratic) instead of reusing the column name', () => { + // The x-column renamed to 'a' collides with the quadratic template's + // hardcoded leading-coefficient letter. + const model = buildCannedModel('quadratic', 'a', 'y1'); + const parsed = parseTildeModel(model, ['a', 'y1']); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.regressors).toEqual(['a']); + expect(parsed.parameters).toHaveLength(3); + } + }); }); describe('fitModel — parameter recovery', () => { @@ -183,6 +218,14 @@ describe('fitModel — hygiene and honest failure', () => { expect(fit.parameters['b']).toBeCloseTo(1, 6); }); + it('warns instead of silently truncating ragged (unequal-length) columns', () => { + // y1 has only 3 values against x1's 5 — the two extra x1 rows must be + // counted as dropped, not silently discarded with an empty warnings list. + const fit = expectOk(fitModel({ x1: [1, 2, 3, 4, 5], y1: [2, 4, 6] }, 'y1 ~ m*x1 + b')); + expect(fit.residuals).toHaveLength(3); + expect(fit.warnings).toContainEqual({ code: 'dropped-rows', count: 2 }); + }); + it('fails with insufficient-data when rows do not exceed parameters', () => { const fit = fitModel({ x1: [1, 2], y1: [2, 4] }, 'y1 ~ m*x1 + b'); expect(fit).toMatchObject({ ok: false, status: 'insufficient-data' }); @@ -245,6 +288,18 @@ describe('fitModel — hygiene and honest failure', () => { expect(Number.isNaN(fit.predict({ x1: -1 }))).toBe(true); }); + it('identifies a well-posed exact fit even at tiny x-magnitudes (e.g. SI meters)', () => { + // A perfectly determined line whose x-values happen to be tiny in + // magnitude (e.g. metres instead of micrometres) must not be + // misclassified as "singular" merely because of the unit choice. + const x1 = [1e-7, 2e-7, 3e-7, 4e-7, 5e-7]; + const y1 = x1.map((x) => 2e6 * x + 1); + const fit = expectOk(fitModel({ x1, y1 }, 'y1 ~ m*x1 + b')); + expect(fit.parameters['m']).toBeCloseTo(2e6, -2); + expect(fit.parameters['b']).toBeCloseTo(1, 6); + expect(fit.r2).toBeCloseTo(1, 6); + }); + it('reports standard errors when the fit is overdetermined', () => { const x1 = [1, 2, 3, 4, 5, 6]; const y1 = [2.1, 3.9, 6.2, 7.8, 10.1, 11.7]; diff --git a/packages/math-engine/src/stats/fit.ts b/packages/math-engine/src/stats/fit.ts index 547f0c73..0d5ecff6 100644 --- a/packages/math-engine/src/stats/fit.ts +++ b/packages/math-engine/src/stats/fit.ts @@ -22,6 +22,7 @@ import { evaluate, extractVariables, isFunctionNode, + isSymbolNode, parse, } from '../parser'; import { differentiate, simplifyDerivative } from '../symbolic/differentiate'; @@ -37,7 +38,8 @@ export type TildeModelErrorCode = | 'multiple-tildes' | 'invalid-dependent' | 'parse-error' - | 'no-parameters'; + | 'no-parameters' + | 'reserved-name-collision'; /** Successfully parsed tilde model (the `ok: true` arm of {@link ParsedModel}). */ export interface TildeModel { @@ -61,6 +63,22 @@ export type ParsedModel = | ({ readonly ok: true } & TildeModel) | { readonly ok: false; readonly code: TildeModelErrorCode; readonly message: string }; +/** All symbol names appearing in an AST, INCLUDING built-in constants + * (unlike {@link extractVariables}, which excludes them). Used to detect + * when a data column shares a name with a built-in constant. */ +function collectAllSymbolNames(node: ExpressionNode): Set { + const names = new Set(); + function traverse(n: ExpressionNode): void { + if (isSymbolNode(n) && n.name) { + names.add(n.name); + } else if (n.args) { + n.args.forEach(traverse); + } + } + traverse(node); + return names; +} + /** * Splits a tilde model like `y1 ~ a*exp(b*x1)` into dependent column, * right-hand-side AST, fit parameters and regressors. @@ -113,6 +131,23 @@ export function parseTildeModel(input: string, columns: readonly string[]): Pars const parameters = [...symbols].filter((s) => !columnSet.has(s)).sort(); const regressors = [...symbols].filter((s) => columnSet.has(s)).sort(); + // A data column named e/pi/tau/i/phi is invisible to extractVariables (it + // is excluded there as a built-in constant), so it would silently never + // become a regressor and the constant's numeric value would be used in + // its place for every row. Detect the collision explicitly instead of + // letting the fit quietly ignore the column's actual data. + const allSymbols = collectAllSymbolNames(rhsAst); + const reservedCollisions = [...allSymbols] + .filter((s) => !symbols.has(s) && columnSet.has(s)) + .sort(); + if (reservedCollisions.length > 0) { + return { + ok: false, + code: 'reserved-name-collision', + message: `Column name "${reservedCollisions[0]}" collides with a built-in constant (e, pi, tau, i, phi) and cannot be used on the right-hand side of "~" — rename the column`, + }; + } + if (parameters.length === 0) { return { ok: false, @@ -133,25 +168,104 @@ export type CannedModelKind = | 'logistic' | 'sinusoidal'; +/** Fallback letters tried, in order, when a canned model's hardcoded parameter + * letter collides with a data column name (e.g. the y-column renamed to 'b'). */ +const PARAM_LETTER_FALLBACKS: readonly string[] = [ + 'm', + 'a', + 'b', + 'c', + 'd', + 'k', + 'p', + 'q', + 'r', + 's', + 'u', + 'v', + 'w', +]; + +/** + * Resolves a canned model's hardcoded parameter letter to one that doesn't + * collide with a data column name or another parameter already placed in + * this formula — otherwise `parseTildeModel` would silently reclassify the + * colliding letter as a regressor instead of a fit parameter (see + * buildCannedModel's honesty w.r.t. column-name collisions). + */ +function resolveParamLetter( + preferred: string, + reserved: ReadonlySet, + used: Set, +): string { + if (!reserved.has(preferred) && !used.has(preferred)) { + used.add(preferred); + return preferred; + } + for (const candidate of PARAM_LETTER_FALLBACKS) { + if (!reserved.has(candidate) && !used.has(candidate)) { + used.add(candidate); + return candidate; + } + } + // Pathological: every fallback letter is also a column name. Suffix until unique. + let n = 2; + let candidate = `${preferred}${n}`; + while (reserved.has(candidate) || used.has(candidate)) { + n += 1; + candidate = `${preferred}${n}`; + } + used.add(candidate); + return candidate; +} + /** * Builds the model string for a canned model family over the given columns. + * Parameter letters are renamed away from `xColumn`/`yColumn` (and from each + * other) when they would otherwise collide with a data column name. * * @example buildCannedModel('exponential', 'x1', 'y1') // 'y1 ~ a*exp(b*x1)' */ export function buildCannedModel(kind: CannedModelKind, xColumn: string, yColumn: string): string { + const reserved = new Set([xColumn, yColumn]); + const used = new Set(); + const letter = (preferred: string): string => resolveParamLetter(preferred, reserved, used); + switch (kind) { - case 'linear': - return `${yColumn} ~ m*${xColumn} + b`; - case 'quadratic': - return `${yColumn} ~ a*${xColumn}^2 + b*${xColumn} + c`; - case 'exponential': - return `${yColumn} ~ a*exp(b*${xColumn})`; - case 'logarithmic': - return `${yColumn} ~ a + b*ln(${xColumn})`; - case 'logistic': - return `${yColumn} ~ L/(1 + exp(-k*(${xColumn} - x0)))`; - case 'sinusoidal': - return `${yColumn} ~ a*sin(b*${xColumn} + c) + d`; + case 'linear': { + const m = letter('m'); + const b = letter('b'); + return `${yColumn} ~ ${m}*${xColumn} + ${b}`; + } + case 'quadratic': { + const a = letter('a'); + const b = letter('b'); + const c = letter('c'); + return `${yColumn} ~ ${a}*${xColumn}^2 + ${b}*${xColumn} + ${c}`; + } + case 'exponential': { + const a = letter('a'); + const b = letter('b'); + return `${yColumn} ~ ${a}*exp(${b}*${xColumn})`; + } + case 'logarithmic': { + const a = letter('a'); + const b = letter('b'); + return `${yColumn} ~ ${a} + ${b}*ln(${xColumn})`; + } + case 'logistic': { + const l = letter('L'); + const k = letter('k'); + const x0 = letter('x0'); + return `${yColumn} ~ ${l}/(1 + exp(-${k}*(${xColumn} - ${x0})))`; + } + case 'sinusoidal': { + const a = letter('a'); + const b = letter('b'); + const c = letter('c'); + const d = letter('d'); + return `${yColumn} ~ ${a}*sin(${b}*${xColumn} + ${c}) + ${d}`; + } } } @@ -221,6 +335,34 @@ export interface FitFailure { /** Discriminated fit result: check `.ok` before reading fit statistics. */ export type FitResult = FitSuccess | FitFailure; +/** + * Fallback solve for the damped normal equations: column-scales JᵀJ + * (Marquardt's original scaling) so `solveLinearSystem`'s absolute pivot + * threshold is scale-invariant, then un-scales the result. Used ONLY when + * the raw (unscaled) solve reports singular — well-conditioned fits keep + * their existing unscaled numeric path unchanged. Without this, well-posed + * data whose regressor has a tiny physical magnitude (e.g. micrometers + * stored as meters) can trip the solver's epsilon purely from unit choice, + * misreporting an identifiable fit as "singular". + */ +function solveScaledFallback( + jtj: readonly (readonly number[])[], + jtr: readonly number[], + lambda: number, +): number[] | null { + const p = jtr.length; + const scale = jtj.map((row, j) => { + const diag = row[j] ?? 0; + return diag > 0 ? Math.sqrt(diag) : 1; + }); + const scaledJtj = jtj.map((row, j) => row.map((v, k) => v / ((scale[j] ?? 1) * (scale[k] ?? 1)))); + const dampedScaled = scaledJtj.map((row, j) => row.map((v, k) => (j === k ? v + lambda : v))); + const scaledJtr = jtr.map((v, j) => v / (scale[j] ?? 1)); + const scaledDelta = solveLinearSystem(dampedScaled, scaledJtr); + if (scaledDelta === null) return null; + return Array.from({ length: p }, (_, j) => (scaledDelta[j] ?? 0) / (scale[j] ?? 1)); +} + /** Coerces an evaluator result to a finite number, or null on any failure. */ function toFiniteNumber(result: EvaluationResult): number | null { if (!result.success) return null; @@ -361,9 +503,13 @@ export function fitModel( }; } - // Assemble finite rows over the used columns. + // Assemble finite rows over the used columns. `rowCount` is the LONGEST + // used column (not the shortest): a ragged tail beyond a shorter column is + // missing data, not absent rows, and must fall into the same + // finite-row check below so it is counted in the 'dropped-rows' warning + // instead of being silently truncated away with no warning at all. const usedColumns = [parsed.dependent, ...parsed.regressors]; - const rowCount = Math.min(...usedColumns.map((c) => data[c]?.length ?? 0)); + const rowCount = Math.max(...usedColumns.map((c) => data[c]?.length ?? 0)); const rows: Record[] = []; const y: number[] = []; for (let i = 0; i < rowCount; i++) { @@ -499,7 +645,7 @@ export function fitModel( row.map((v, k) => (j === k ? v + lambda * Math.max(v, 1e-12) : v)), ); - const delta = solveLinearSystem(damped, jtr); + const delta = solveLinearSystem(damped, jtr) ?? solveScaledFallback(jtj, jtr, lambda); if (delta === null) { consecutiveSingular += 1; if (consecutiveSingular >= 5) {