diff --git a/.changeset/quiet-pandas-cheer.md b/.changeset/quiet-pandas-cheer.md new file mode 100644 index 00000000..f42f712c --- /dev/null +++ b/.changeset/quiet-pandas-cheer.md @@ -0,0 +1,6 @@ +--- +'@boostv/process-optimizer-frontend-core': minor +'@boostv/process-optimizer-frontend-ui': minor +--- + +Add score functions: compute an objective's score from a per-objective formula. diff --git a/docs/adr/0003-score-functions-additive-data-model.md b/docs/adr/0003-score-functions-additive-data-model.md new file mode 100644 index 00000000..66b99479 --- /dev/null +++ b/docs/adr/0003-score-functions-additive-data-model.md @@ -0,0 +1,64 @@ +# 0003. Score functions: additive data model that persists into the existing score entry + +- **Status:** accepted +- **Date:** 2026-07-24 +- **Deciders:** Jack Ord + +## Context + +Users need to define a per-objective **score function** — a mathjs expression +over named "responses" (measured values entered per data point) and existing +factors — that computes each data point's quality/cost score, instead of typing +the score by hand. + +The score value is load-bearing downstream: the optimizer request builder, the +Pareto selection, the plots, and the CSV export all read it from the existing +`{ type: 'score', name, value }` entry in a data point's `data` array. Anything +that changed that contract would ripple into the Python optimizer API and every +consumer of experiment data. + +## Decision + +Model score functions as **purely additive** to the experiment data model in +`@boostv/process-optimizer-frontend-core`, behind a `v21` data-format migration: + +- `scoreVariable.scoreFunction` — the expression plus its variable definitions + (each `{ name, symbol, source: 'response' | 'factor', factorName? }`). +- `dataEntry.responses` — per-data-point response values plus a `useFunction` + toggle (function-computed vs. manually typed). + +The computed value (or the typed value, in manual mode) **continues to live in +the existing `{ type: 'score', name, value }` entry**. The reducer computes it +(`computeScore`) and writes it there; a new function-mode point that has no +score entry yet gets one created, inserted in canonical order so multi-objective +score columns stay positionally aligned. Nothing downstream is modified — the +optimizer request, Pareto, plots, and CSV all keep reading the same entry. + +Key commits: `7d9e459` (schema + v21 migration), `5d7a451` (reducer actions), +`27055cf` (compute/persist for new points, preserving score order). + +## Consequences + +- **Easier:** zero changes to the optimizer/backend or any downstream consumer; + pre-`v21` experiments migrate transparently because the new fields are + optional and additive. +- **Harder / accepted costs:** the score is now sometimes _derived_ state — the + reducer must recompute and persist it when the function or a row's responses + change, and a validation flags rows whose responses are missing (excluded from + the optimizer via `meta.valid`). The UI must track function-vs-manual mode per + row. Expression evaluation runs client-side via mathjs. +- New data points default to using a defined function; pre-existing points stay + manual until switched, so existing hand-entered scores are never silently + overwritten. + +## Alternatives considered + +- **A separate "computed score" field, distinct from the existing score entry.** + Rejected: every downstream consumer (optimizer request, Pareto, plots, CSV) + would have to learn about it, defeating the goal of leaving them untouched. +- **Compute scores in the backend / optimizer API.** Rejected: couples the + statistical engine to a UI-authoring concern, adds round-trips, and pushes + expression parsing into Python. +- **Store responses inside the `{ type: 'score' }` entry.** Rejected: overloads + the shape downstream code reads; a separate additive `responses` field keeps + the score entry's contract intact. diff --git a/docs/adr/README.md b/docs/adr/README.md index c52cf33e..d7a50624 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,7 +32,8 @@ record no ADR — that's expected. One ADR per branch is a smell. ## Index -| ADR | Title | Status | -| ------------------------------------------------ | ---------------------------------------------------------------- | -------- | -| [0001](0001-pareto-uncertainty-visualization.md) | Pareto front uncertainty: band view + opt-in hover ellipse | accepted | -| [0002](0002-adrs-and-ephemeral-agent-docs.md) | Record decisions in ADRs; treat agent plans/specs as branch-only | accepted | +| ADR | Title | Status | +| --------------------------------------------------- | ----------------------------------------------------------------------------- | -------- | +| [0001](0001-pareto-uncertainty-visualization.md) | Pareto front uncertainty: band view + opt-in hover ellipse | accepted | +| [0002](0002-adrs-and-ephemeral-agent-docs.md) | Record decisions in ADRs; treat agent plans/specs as branch-only | accepted | +| [0003](0003-score-functions-additive-data-model.md) | Score functions: additive data model persisting into the existing score entry | accepted | diff --git a/package-lock.json b/package-lock.json index 77f15668..944e2541 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4805,6 +4805,19 @@ "version": "6.1.1", "license": "MIT" }, + "node_modules/complex.js": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.3.tgz", + "integrity": "sha512-UrQVSUur14tNX6tiP4y8T4w4FeJAX3bi2cIv0pu/DTLFNxoq7z2Yh83Vfzztj6Px3X/lubqQ9IrPp7Bpn6p4MQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/concat-map": { "version": "0.0.1", "dev": true, @@ -5246,7 +5259,6 @@ }, "node_modules/decimal.js": { "version": "10.6.0", - "dev": true, "license": "MIT" }, "node_modules/decimal.js-light": { @@ -5860,6 +5872,12 @@ "node": ">=6" } }, + "node_modules/escape-latex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", + "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "license": "MIT", @@ -6694,6 +6712,19 @@ "node": ">= 6" } }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fresh": { "version": "0.5.2", "dev": true, @@ -7912,6 +7943,12 @@ "node": ">= 0.4" } }, + "node_modules/javascript-natural-sort": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", + "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", + "license": "MIT" + }, "node_modules/jquery": { "version": "4.0.0", "license": "MIT" @@ -8810,6 +8847,29 @@ "speech-rule-engine": "^4.0.6" } }, + "node_modules/mathjs": { + "version": "14.9.1", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-14.9.1.tgz", + "integrity": "sha512-xhqv8Xjf+caWG3WlaPekg4v8QFOR3D5+8ycfcjMcPcnCNDgAONQLaLfyGgrggJrcHx2yUGCpACRpiD4GmXwX+Q==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/md5": { "version": "2.3.0", "license": "BSD-3-Clause", @@ -10851,6 +10911,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, "node_modules/semver": { "version": "5.7.1", "dev": true, @@ -11458,6 +11524,12 @@ "version": "1.0.23", "license": "MIT" }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "license": "MIT" @@ -11761,6 +11833,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-function": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.2.tgz", + "integrity": "sha512-VwaXim9Gp1bngi/q3do8hgttYn2uC3MoT/gfuMWylnj1IeZBUAyPddHZlo1K05BDoj8DYPpMdiHqH1dDYdJf2A==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -12750,7 +12831,7 @@ }, "packages/core": { "name": "@boostv/process-optimizer-frontend-core", - "version": "2.14.1", + "version": "2.16.1", "license": "BSD-3-Clause", "dependencies": { "@boostv/process-optimizer-frontend-api": "1.5.1", @@ -12821,7 +12902,7 @@ }, "packages/plots": { "name": "@boostv/process-optimizer-frontend-plots", - "version": "2.0.2", + "version": "3.0.2", "license": "BSD-3-Clause", "dependencies": { "@bokeh/bokehjs": "^3.9.0", @@ -12842,12 +12923,13 @@ }, "packages/ui": { "name": "@boostv/process-optimizer-frontend-ui", - "version": "2.14.3", + "version": "3.1.3", "license": "BSD-3-Clause", "dependencies": { - "@boostv/process-optimizer-frontend-core": "2.14.1", - "@boostv/process-optimizer-frontend-plots": "2.0.2", + "@boostv/process-optimizer-frontend-core": "2.16.1", + "@boostv/process-optimizer-frontend-plots": "3.0.2", "lodash.debounce": "^4.0.8", + "mathjs": "^14.8.0", "react-hook-form": "^7.76.1", "remeda": "^2.37.0", "tss-react": "^4.9.21" @@ -12891,12 +12973,12 @@ }, "sample-app": { "name": "@boostv/process-optimizer-frontend-sample-app", - "version": "2.3.8", + "version": "3.0.4", "license": "BSD-3-Clause", "dependencies": { - "@boostv/process-optimizer-frontend-core": "2.14.1", - "@boostv/process-optimizer-frontend-plots": "2.0.2", - "@boostv/process-optimizer-frontend-ui": "2.14.3", + "@boostv/process-optimizer-frontend-core": "2.16.1", + "@boostv/process-optimizer-frontend-plots": "3.0.2", + "@boostv/process-optimizer-frontend-ui": "3.1.3", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/icons-material": "^9.0.1", diff --git a/packages/core/src/common/types/common.test.ts b/packages/core/src/common/types/common.test.ts index dffb9574..c79febcd 100644 --- a/packages/core/src/common/types/common.test.ts +++ b/packages/core/src/common/types/common.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { emptyExperiment } from '@core/context' -import { ExperimentType, isExperiment } from './common' +import { + ExperimentType, + isExperiment, + experimentSchema, + currentVersion, +} from './common' describe('Type guards', () => { it('should not narrow blank object', () => { @@ -34,3 +39,130 @@ describe('Type guards', () => { } }) }) + +describe('score function schema (v21)', () => { + it('currentVersion is 21', () => { + expect(currentVersion).toBe('21') + }) + + it('accepts a scoreVariable with a scoreFunction and a dataEntry with responses', () => { + const experiment = { + id: 'x', + changedSinceLastEvaluation: false, + info: { + name: 'n', + description: '', + swVersion: 'v', + dataFormatVersion: '21', + version: 0, + lastModified: '', + createdAt: '', + extras: {}, + }, + extras: {}, + categoricalVariables: [], + valueVariables: [], + scoreVariables: [ + { + name: 'quality', + label: 'Quality (0-5)', + description: '', + enabled: true, + scoreFunction: { + expression: 'weight/2 + firingAngle', + variables: [ + { name: 'Weight', symbol: 'weight', source: 'response' }, + { + name: 'Firing angle', + symbol: 'firingAngle', + source: 'factor', + factorName: 'Firing angle', + }, + ], + }, + }, + ], + constraints: [{ type: 'sum', value: 0, dimensions: [] }], + optimizerConfig: { + baseEstimator: 'GP', + acqFunc: 'EI', + initialPoints: 3, + kappa: 1.96, + xi: 0.01, + }, + results: { + id: '', + plots: [], + next: [], + pickled: '', + expectedMinimum: [], + extras: {}, + }, + dataPoints: [ + { + meta: { id: 1, enabled: true, valid: true }, + data: [{ type: 'score', name: 'quality', value: 2.5 }], + responses: [ + { + scoreName: 'quality', + useFunction: true, + values: [{ symbol: 'weight', value: 150 }], + }, + ], + }, + ], + } + expect(experimentSchema.safeParse(experiment).success).toBe(true) + }) + + it('still accepts an experiment with neither scoreFunction nor responses', () => { + const parsed = experimentSchema.safeParse({ + id: 'x', + changedSinceLastEvaluation: false, + info: { + name: 'n', + description: '', + swVersion: 'v', + dataFormatVersion: '21', + version: 0, + lastModified: '', + createdAt: '', + extras: {}, + }, + extras: {}, + categoricalVariables: [], + valueVariables: [], + scoreVariables: [ + { + name: 'quality', + label: 'Quality (0-5)', + description: '', + enabled: true, + }, + ], + constraints: [{ type: 'sum', value: 0, dimensions: [] }], + optimizerConfig: { + baseEstimator: 'GP', + acqFunc: 'EI', + initialPoints: 3, + kappa: 1.96, + xi: 0.01, + }, + results: { + id: '', + plots: [], + next: [], + pickled: '', + expectedMinimum: [], + extras: {}, + }, + dataPoints: [ + { + meta: { id: 1, enabled: true, valid: true }, + data: [{ type: 'score', name: 'quality', value: 2.5 }], + }, + ], + }) + expect(parsed.success).toBe(true) + }) +}) diff --git a/packages/core/src/common/types/common.ts b/packages/core/src/common/types/common.ts index 1de2ea49..9393c8d9 100644 --- a/packages/core/src/common/types/common.ts +++ b/packages/core/src/common/types/common.ts @@ -3,7 +3,7 @@ import { z } from 'zod' // Change the current version when doing structural // changes to any types belonging to ExperimentType -export const currentVersion = '20' +export const currentVersion = '21' export const scoreNames = ['quality', 'cost'] as const // Label is shown in UI, name is used in data @@ -60,11 +60,29 @@ const valueVariableSchema = z.object({ enabled: z.boolean(), }) +const scoreFunctionVariableSchema = z.object({ + name: z.string(), + symbol: z.string(), + // z.enum (not z.union of literals) — at this nesting depth, json-schema-faker's + // fuzz-testing in migration.test.ts reliably fakes a union-of-literals field as + // `null`, failing schema validation 100% of the time; z.enum has an identical + // JSON-schema `enum` (not `anyOf`) shape that the faker handles correctly, with + // the same runtime validation and inferred `'response' | 'factor'` type. + source: z.enum(['response', 'factor']), + factorName: z.string().optional(), +}) + +const scoreFunctionSchema = z.object({ + expression: z.string(), + variables: z.array(scoreFunctionVariableSchema), +}) + const scoreVariableSchema = z.object({ name: z.literal(scoreNames[0]).or(z.literal(scoreNames[1])), label: z.string(), description: z.string(), enabled: z.boolean(), + scoreFunction: scoreFunctionSchema.optional(), }) const optimizerSchema = z.object({ @@ -106,9 +124,21 @@ export const dataPointSchema = z.discriminatedUnion('type', [ scoreDataPoint, ]) +const responseValueSchema = z.object({ + symbol: z.string(), + value: z.number(), +}) + +const scoreResponsesSchema = z.object({ + scoreName: z.literal(scoreNames[0]).or(z.literal(scoreNames[1])), + useFunction: z.boolean(), + values: z.array(responseValueSchema), +}) + const dataEntrySchema = z.object({ meta: dataEntryMetaDataSchema, data: z.array(dataPointSchema), + responses: z.array(scoreResponsesSchema).optional(), }) export const spaceSchema = z @@ -168,6 +198,13 @@ export type CategoricalVariableType = z.infer export type ValueVariableType = z.infer export type ScoreVariableType = z.infer export type OptimizerConfig = z.infer +export type ScoreName = (typeof scoreNames)[number] +export type ScoreFunctionType = z.infer +export type ScoreFunctionVariableType = z.infer< + typeof scoreFunctionVariableSchema +> +export type ScoreResponsesType = z.infer +export type ResponseValueType = z.infer // Type guards export function isExperiment(obj: unknown): obj is ExperimentType { diff --git a/packages/core/src/common/util/index.ts b/packages/core/src/common/util/index.ts index 64204a06..dbb903f8 100644 --- a/packages/core/src/common/util/index.ts +++ b/packages/core/src/common/util/index.ts @@ -4,6 +4,7 @@ export * from './save-to-local-file' export * from './data-type-detectors' export * from './scores/quality' export * from './pareto/pareto-plot' +export * from './score' export const errorMessage = (e: unknown) => { if (typeof e === 'string') { diff --git a/packages/core/src/common/util/migration/data-formats/21.json b/packages/core/src/common/util/migration/data-formats/21.json new file mode 100644 index 00000000..53683870 --- /dev/null +++ b/packages/core/src/common/util/migration/data-formats/21.json @@ -0,0 +1,105 @@ +{ + "id": "1234", + "changedSinceLastEvaluation": true, + "lastEvaluationHash": "never-calculated", + "info": { + "dataFormatVersion": "21", + "swVersion": "v1.2.0-16", + "name": "Cake", + "description": "Yummy", + "version": 0, + "extras": {}, + "lastModified": "", + "createdAt": "" + }, + "categoricalVariables": [ + { + "name": "Icing", + "description": "Sugary", + "options": ["White", "Brown"], + "enabled": true + } + ], + "valueVariables": [ + { + "name": "name1", + "description": "desc1", + "min": 10, + "max": 100, + "type": "discrete", + "enabled": true + }, + { + "name": "name2", + "description": "desc2", + "min": 10.2, + "max": 100.3, + "type": "continuous", + "enabled": true + } + ], + "scoreVariables": [ + { + "name": "quality", + "label": "Quality (0-5)", + "description": "", + "enabled": true + } + ], + "constraints": [ + { + "type": "sum", + "dimensions": [], + "value": 0 + } + ], + "optimizerConfig": { + "baseEstimator": "GP", + "acqFunc": "EI", + "initialPoints": 3, + "kappa": 1.96, + "xi": 0.01 + }, + "results": { + "id": "", + "next": [[]], + "plots": [], + "pickled": "", + "expectedMinimum": [], + "extras": {} + }, + "dataPoints": [ + { + "meta": { + "enabled": true, + "valid": true, + "id": 1 + }, + "data": [ + { + "type": "categorical", + "name": "Icing", + "value": "Brown" + }, + { + "type": "numeric", + "name": "name1", + "value": 10 + }, + { + "type": "numeric", + "name": "name2", + "value": 10.2 + }, + { + "type": "score", + "name": "quality", + "value": 0.5 + } + ] + } + ], + "extras": { + "experimentSuggestionCount": 1 + } +} diff --git a/packages/core/src/common/util/migration/migration.test.ts b/packages/core/src/common/util/migration/migration.test.ts index df4a76f9..231261fe 100644 --- a/packages/core/src/common/util/migration/migration.test.ts +++ b/packages/core/src/common/util/migration/migration.test.ts @@ -24,6 +24,7 @@ import { storeLatestSchema, loadTestData } from './test-utils' import { scoreName17 } from './migrations/migrateToV17' import { migrateToV17, migrateToV18 } from './migrations' import { ExperimentTypeV17 } from './migrations/migrateToV18' +import { migrateToV21 } from './migrations/migrateToV21' describe('Migration of data format', () => { storeLatestSchema() @@ -371,3 +372,29 @@ describe('Migration of data format', () => { }) }) }) + +describe('migrateToV21', () => { + it('bumps dataFormatVersion to 21 and preserves data', () => { + const v20 = { + info: { dataFormatVersion: '20', name: 'n' }, + scoreVariables: [ + { + name: 'quality', + label: 'Quality (0-5)', + description: '', + enabled: true, + }, + ], + dataPoints: [ + { + meta: { id: 1, enabled: true, valid: true }, + data: [{ type: 'score', name: 'quality', value: 2 }], + }, + ], + } + const v21 = migrateToV21(v20 as never) + expect(v21.info.dataFormatVersion).toBe('21') + expect(v21.scoreVariables).toEqual(v20.scoreVariables) + expect(v21.dataPoints).toEqual(v20.dataPoints) + }) +}) diff --git a/packages/core/src/common/util/migration/migration.ts b/packages/core/src/common/util/migration/migration.ts index defc9a47..2b8732c8 100644 --- a/packages/core/src/common/util/migration/migration.ts +++ b/packages/core/src/common/util/migration/migration.ts @@ -21,6 +21,7 @@ import { migrateToV18, migrateToV19, migrateToV20, + migrateToV21, } from './migrations' export const migrate = (json: any): ExperimentType => { @@ -106,4 +107,5 @@ export const MIGRATIONS: Migration[] = [ { version: '18', converter: migrateToV18 }, { version: '19', converter: migrateToV19 }, { version: '20', converter: migrateToV20 }, + { version: '21', converter: migrateToV21 }, ] diff --git a/packages/core/src/common/util/migration/migrations/index.ts b/packages/core/src/common/util/migration/migrations/index.ts index 1da83fbb..f5adabdf 100644 --- a/packages/core/src/common/util/migration/migrations/index.ts +++ b/packages/core/src/common/util/migration/migrations/index.ts @@ -16,3 +16,4 @@ export { migrateToV17 } from './migrateToV17' export { migrateToV18 } from './migrateToV18' export { migrateToV19 } from './migrateToV19' export { migrateToV20 } from './migrateToV20' +export { migrateToV21 } from './migrateToV21' diff --git a/packages/core/src/common/util/migration/migrations/migrateToV21.ts b/packages/core/src/common/util/migration/migrations/migrateToV21.ts new file mode 100644 index 00000000..30d33241 --- /dev/null +++ b/packages/core/src/common/util/migration/migrations/migrateToV21.ts @@ -0,0 +1,11 @@ +import { ExperimentType } from '@core/common/types' +import { produce } from 'immer' + +// v21 adds optional score-function definitions (scoreVariable.scoreFunction) and +// per-data-point response inputs (dataEntry.responses). Both are optional, so no +// data transform is needed — existing experiments validate as-is once the version +// literal is bumped. +export const migrateToV21 = (json: ExperimentType): ExperimentType => + produce(json, (draft: { info: { dataFormatVersion: string } }) => { + draft.info.dataFormatVersion = '21' + }) diff --git a/packages/core/src/common/util/migration/schemas/21.json b/packages/core/src/common/util/migration/schemas/21.json new file mode 100644 index 00000000..8d372680 --- /dev/null +++ b/packages/core/src/common/util/migration/schemas/21.json @@ -0,0 +1,528 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "lastEvaluationHash": { + "type": "string" + }, + "changedSinceLastEvaluation": { + "type": "boolean" + }, + "info": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "swVersion": { + "type": "string" + }, + "dataFormatVersion": { + "type": "string", + "const": "21" + }, + "version": { + "type": "number" + }, + "lastModified": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "extras": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "name", + "description", + "swVersion", + "dataFormatVersion", + "version", + "lastModified", + "createdAt", + "extras" + ], + "additionalProperties": false + }, + "extras": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "categoricalVariables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "name", + "description", + "options", + "enabled" + ], + "additionalProperties": false + } + }, + "valueVariables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "anyOf": [ + { + "type": "string", + "const": "discrete" + }, + { + "type": "string", + "const": "continuous" + } + ] + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "type", + "name", + "description", + "min", + "max", + "enabled" + ], + "additionalProperties": false + } + }, + "scoreVariables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "anyOf": [ + { + "type": "string", + "const": "quality" + }, + { + "type": "string", + "const": "cost" + } + ] + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "scoreFunction": { + "type": "object", + "properties": { + "expression": { + "type": "string" + }, + "variables": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "response", + "factor" + ] + }, + "factorName": { + "type": "string" + } + }, + "required": [ + "name", + "symbol", + "source" + ], + "additionalProperties": false + } + } + }, + "required": [ + "expression", + "variables" + ], + "additionalProperties": false + } + }, + "required": [ + "name", + "label", + "description", + "enabled" + ], + "additionalProperties": false + } + }, + "constraints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sum" + }, + "value": { + "type": "number" + }, + "dimensions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "value", + "dimensions" + ], + "additionalProperties": false + } + }, + "optimizerConfig": { + "type": "object", + "properties": { + "baseEstimator": { + "type": "string" + }, + "acqFunc": { + "type": "string" + }, + "initialPoints": { + "type": "number" + }, + "kappa": { + "type": "number" + }, + "xi": { + "type": "number" + } + }, + "required": [ + "baseEstimator", + "acqFunc", + "initialPoints", + "kappa", + "xi" + ], + "additionalProperties": false + }, + "results": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "plots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "plot": { + "type": "string" + } + }, + "required": [ + "id", + "plot" + ], + "additionalProperties": false + } + }, + "next": { + "type": "array", + "items": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + } + }, + "pickled": { + "type": "string" + }, + "expectedMinimum": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "type": "number" + } + ] + } + }, + "extras": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "id", + "plots", + "next", + "pickled", + "expectedMinimum", + "extras" + ], + "additionalProperties": false + }, + "dataPoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "meta": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "enabled": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "enabled", + "valid" + ], + "additionalProperties": false + }, + "data": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "numeric" + }, + "name": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": [ + "type", + "name", + "value" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "categorical" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "name", + "value" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "score" + }, + "name": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": [ + "type", + "name", + "value" + ], + "additionalProperties": false + } + ] + } + }, + "responses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "scoreName": { + "anyOf": [ + { + "type": "string", + "const": "quality" + }, + { + "type": "string", + "const": "cost" + } + ] + }, + "useFunction": { + "type": "boolean" + }, + "values": { + "type": "array", + "items": { + "type": "object", + "properties": { + "symbol": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": [ + "symbol", + "value" + ], + "additionalProperties": false + } + } + }, + "required": [ + "scoreName", + "useFunction", + "values" + ], + "additionalProperties": false + } + } + }, + "required": [ + "meta", + "data" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "changedSinceLastEvaluation", + "info", + "extras", + "categoricalVariables", + "valueVariables", + "scoreVariables", + "constraints", + "optimizerConfig", + "results", + "dataPoints" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/packages/core/src/common/util/score/compute-score.test.ts b/packages/core/src/common/util/score/compute-score.test.ts new file mode 100644 index 00000000..697e0ace --- /dev/null +++ b/packages/core/src/common/util/score/compute-score.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from 'vitest' +import { + computeScore, + deriveSymbol, + usedSymbols, + findUndefinedSymbols, + findDisabledFactors, +} from './compute-score' +import type { ScoreFunctionType, DataPointType } from '@core/common/types' + +describe('deriveSymbol', () => { + it('sanitises names to valid identifiers', () => { + expect(deriveSymbol('Wait before stir', [])).toBe('waitBeforeStir') + expect(deriveSymbol('Weight', [])).toBe('weight') + }) + it('enforces uniqueness with numeric suffixes', () => { + expect(deriveSymbol('Weight', ['weight'])).toBe('weight_2') + expect(deriveSymbol('Weight', ['weight', 'weight_2'])).toBe('weight_3') + }) +}) + +describe('usedSymbols', () => { + it('returns referenced symbols', () => { + expect(usedSymbols('weight/2 + viscosity*2').sort()).toEqual([ + 'viscosity', + 'weight', + ]) + }) + it('returns [] for an unparseable expression', () => { + expect(usedSymbols('weight/')).toEqual([]) + }) +}) + +describe('findUndefinedSymbols', () => { + it('finds symbols used in the expression that are not declared (excluding math builtins)', () => { + const fn = { + expression: 'a + b * pi + water', + variables: [ + { name: 'water', symbol: 'water', source: 'response' as const }, + ], + } + expect(findUndefinedSymbols(fn).sort()).toEqual(['a', 'b']) + }) + + it('does not flag math builtins (constants or functions)', () => { + const fn = { + expression: 'pi + e + sin(0) + sqrt(4)', + variables: [], + } + expect(findUndefinedSymbols(fn)).toEqual([]) + }) +}) + +describe('computeScore', () => { + const fn: ScoreFunctionType = { + expression: 'weight/2 + firingAngle/100', + variables: [ + { name: 'Weight', symbol: 'weight', source: 'response' }, + { + name: 'Firing angle', + symbol: 'firingAngle', + source: 'factor', + factorName: 'Firing angle', + }, + ], + } + const factorData: DataPointType[] = [ + { type: 'numeric', name: 'Firing angle', value: 120 }, + ] + + it('evaluates responses + factors', () => { + expect( + computeScore(fn, [{ symbol: 'weight', value: 10 }], factorData) + ).toBeCloseTo(6.2) + }) + it('returns undefined when a response value is missing', () => { + expect(computeScore(fn, [], factorData)).toBeUndefined() + }) + it('returns undefined when a referenced factor is absent from the data point', () => { + expect( + computeScore(fn, [{ symbol: 'weight', value: 10 }], []) + ).toBeUndefined() + }) + it('supports a response-only function', () => { + const respFn: ScoreFunctionType = { + expression: 'a + b', + variables: [ + { name: 'A', symbol: 'a', source: 'response' }, + { name: 'B', symbol: 'b', source: 'response' }, + ], + } + expect( + computeScore( + respFn, + [ + { symbol: 'a', value: 2 }, + { symbol: 'b', value: 3 }, + ], + [] + ) + ).toBe(5) + }) + + it('ignores declared variables the expression does not use', () => { + // expression uses only `water`; the extra factor vars have no data — must NOT bail + const fn = { + expression: 'water', + variables: [ + { + name: 'Pin elevation', + symbol: 'pinElevation', + source: 'factor' as const, + factorName: 'Pin elevation', + }, + { + name: 'Orangeknas', + symbol: 'orangeknas', + source: 'factor' as const, + factorName: 'Orangeknas', + }, + { name: 'water', symbol: 'water', source: 'response' as const }, + ], + } + const factorData = [ + { type: 'numeric' as const, name: 'Pin elevation', value: 130 }, + ] + expect(computeScore(fn, [{ symbol: 'water', value: 4 }], factorData)).toBe( + 4 + ) + }) + + it('still returns undefined when a USED symbol cannot be resolved', () => { + const fn = { + expression: 'pinElevation + water', + variables: [ + { + name: 'Pin elevation', + symbol: 'pinElevation', + source: 'factor' as const, + factorName: 'Pin elevation', + }, + { name: 'water', symbol: 'water', source: 'response' as const }, + ], + } + // Pin elevation used but absent from factorData -> undefined + expect( + computeScore(fn, [{ symbol: 'water', value: 4 }], []) + ).toBeUndefined() + }) +}) + +describe('findDisabledFactors', () => { + const fnUsing = (factorName: string, symbol: string): ScoreFunctionType => ({ + expression: symbol, + variables: [{ name: factorName, symbol, source: 'factor', factorName }], + }) + + it('flags a used factor that is not an enabled value variable', () => { + expect( + findDisabledFactors(fnUsing('Pin elevation', 'pinElevation'), []) + ).toEqual(['Pin elevation']) + }) + + it('does not flag when the factor is enabled', () => { + expect( + findDisabledFactors(fnUsing('Pin elevation', 'pinElevation'), [ + 'Pin elevation', + ]) + ).toEqual([]) + }) + + it('ignores a disabled factor the expression does not use', () => { + const fn: ScoreFunctionType = { + expression: 'water', + variables: [ + { + name: 'Orangeknas', + symbol: 'orangeknas', + source: 'factor', + factorName: 'Orangeknas', + }, + { name: 'water', symbol: 'water', source: 'response' }, + ], + } + expect(findDisabledFactors(fn, [])).toEqual([]) + }) +}) diff --git a/packages/core/src/common/util/score/compute-score.ts b/packages/core/src/common/util/score/compute-score.ts new file mode 100644 index 00000000..791acc6b --- /dev/null +++ b/packages/core/src/common/util/score/compute-score.ts @@ -0,0 +1,114 @@ +import { parse, evaluate, isSymbolNode } from 'mathjs' +import type { ScoreFunctionType, DataPointType } from '@core/common/types' + +// Turn a human display name into a valid, unique mathjs identifier. +// "Wait before stir" -> "waitBeforeStir"; collisions get _2, _3, ... +export const deriveSymbol = (name: string, existing: string[]): string => { + const words = name + .replace(/[^a-zA-Z0-9 ]/g, ' ') + .trim() + .split(/\s+/) + .filter(Boolean) + let base = + words + .map((w, i) => + i === 0 + ? w.toLowerCase() + : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase() + ) + .join('') || 'x' + if (/^[0-9]/.test(base)) base = 'x' + base + let symbol = base + let n = 2 + while (existing.includes(symbol)) { + symbol = `${base}_${n}` + n += 1 + } + return symbol +} + +// Symbol names referenced by an expression. [] if it does not parse. +export const usedSymbols = (expression: string): string[] => { + try { + const node = parse(expression) + const symbols = new Set() + node.filter(isSymbolNode).forEach(n => { + if (isSymbolNode(n)) symbols.add(n.name) + }) + return [...symbols] + } catch { + return [] + } +} + +// Symbols the expression references that are neither declared variables nor +// known to mathjs (so `pi`, `sin`, ... are not flagged). +export const findUndefinedSymbols = (fn: ScoreFunctionType): string[] => { + const declared = new Set(fn.variables.map(v => v.symbol)) + return usedSymbols(fn.expression).filter(s => { + if (declared.has(s)) return false + try { + const value = evaluate(s) + // Real math constants/functions (pi, e, sin, ...) are fine; a bare mathjs + // unit (m, s, g, b, ...) or other non-numeric value isn't meaningful in a + // score function, so treat it as an undefined symbol. + return typeof value !== 'number' && typeof value !== 'function' + } catch { + return true + } + }) +} + +// Factor names the expression actually uses but that are not currently enabled +// value variables (disabled or removed). Their values won't be available, so a +// point using this function can't compute. Unused declared factors are ignored +// (computeScore already ignores them), so this only flags factors that matter. +export const findDisabledFactors = ( + fn: ScoreFunctionType, + enabledFactorNames: string[] +): string[] => { + const enabled = new Set(enabledFactorNames) + const used = new Set(usedSymbols(fn.expression)) + return [ + ...new Set( + fn.variables + .filter( + v => + v.source === 'factor' && + used.has(v.symbol) && + !enabled.has(v.factorName ?? '') + ) + .map(v => v.factorName ?? v.name) + ), + ] +} + +export const computeScore = ( + fn: ScoreFunctionType, + responseValues: { symbol: string; value: number }[], + factorData: DataPointType[] +): number | undefined => { + const used = new Set(usedSymbols(fn.expression)) + const scope: Record = {} + for (const variable of fn.variables) { + if (!used.has(variable.symbol)) continue + if (variable.source === 'response') { + const rv = responseValues.find(r => r.symbol === variable.symbol) + if (rv === undefined || !Number.isFinite(rv.value)) return undefined + scope[variable.symbol] = rv.value + } else { + const fd = factorData.find(d => d.name === variable.factorName) + const value = fd === undefined ? undefined : Number(fd.value) + if (value === undefined || !Number.isFinite(value)) return undefined + scope[variable.symbol] = value + } + } + try { + const result = evaluate(fn.expression, scope) + return typeof result === 'number' && Number.isFinite(result) + ? result + : undefined + } catch { + return undefined + } +} diff --git a/packages/core/src/common/util/score/index.ts b/packages/core/src/common/util/score/index.ts new file mode 100644 index 00000000..40e9fec6 --- /dev/null +++ b/packages/core/src/common/util/score/index.ts @@ -0,0 +1,7 @@ +export { + computeScore, + deriveSymbol, + usedSymbols, + findUndefinedSymbols, + findDisabledFactors, +} from './compute-score' diff --git a/packages/core/src/context/experiment/experiment-reducers.test.ts b/packages/core/src/context/experiment/experiment-reducers.test.ts index cc6d4590..31503671 100644 --- a/packages/core/src/context/experiment/experiment-reducers.test.ts +++ b/packages/core/src/context/experiment/experiment-reducers.test.ts @@ -132,3 +132,215 @@ describe('pareto selection invalidation policy', () => { expect(actual.experiment.extras.selectedPoint).toEqual([1.5]) }) }) + +describe('setDataPointsUseFunction', () => { + const baseExperiment = { + ...emptyExperiment, + valueVariables: [ + { + type: 'continuous' as const, + name: 'F', + description: '', + min: 0, + max: 10, + enabled: true, + }, + ], + scoreVariables: [ + { + name: 'quality' as const, + label: 'Quality (0-5)', + description: '', + enabled: true, + scoreFunction: { + expression: 'w * 2', + variables: [{ name: 'W', symbol: 'w', source: 'response' as const }], + }, + }, + ], + dataPoints: [ + { + meta: { id: 1, enabled: true, valid: true }, + data: [ + { type: 'numeric' as const, name: 'F', value: 3 }, + { type: 'score' as const, name: 'quality', value: 0 }, + ], + responses: [ + { + scoreName: 'quality' as const, + useFunction: false, + values: [{ symbol: 'w', value: 4 }], + }, + ], + }, + { + meta: { id: 2, enabled: true, valid: true }, + data: [ + { type: 'numeric' as const, name: 'F', value: 5 }, + { type: 'score' as const, name: 'quality', value: 1.5 }, + ], + // no responses entry -> missing the required 'w' response + }, + ], + } + + it('enables the function for all data points and recomputes where responses exist', () => { + const state = rootReducer({ experiment: baseExperiment } as State, { + type: 'setDataPointsUseFunction', + payload: { scoreName: 'quality', useFunction: true }, + }) + const dps = state.experiment.dataPoints + // row 1: complete responses -> useFunction on, score recomputed to w*2 = 8 + expect(dps[0]?.responses?.[0]).toMatchObject({ + scoreName: 'quality', + useFunction: true, + }) + expect( + dps[0]?.data.find(d => d.type === 'score' && d.name === 'quality')?.value + ).toBe(8) + expect(dps[0]?.meta.valid).toBe(true) + // row 2: enabled but missing responses -> created entry, marked invalid + expect( + dps[1]?.responses?.find(r => r.scoreName === 'quality')?.useFunction + ).toBe(true) + expect(dps[1]?.meta.valid).toBe(false) + }) + + it('disables the function only on rows that already have a responses entry', () => { + const enabled = rootReducer({ experiment: baseExperiment } as State, { + type: 'setDataPointsUseFunction', + payload: { scoreName: 'quality', useFunction: true }, + }) + const disabled = rootReducer(enabled, { + type: 'setDataPointsUseFunction', + payload: { scoreName: 'quality', useFunction: false }, + }) + disabled.experiment.dataPoints.forEach(dp => { + const resp = dp.responses?.find(r => r.scoreName === 'quality') + if (resp) expect(resp.useFunction).toBe(false) + }) + }) +}) + +describe('copySuggestedToDataPoints defaults to the score function', () => { + const baseExperiment = { + ...emptyExperiment, + valueVariables: [ + { + type: 'discrete' as const, + name: 'F', + description: '', + min: 0, + max: 10, + enabled: true, + }, + ], + scoreVariables: [ + { + name: 'quality' as const, + label: 'Quality (0-5)', + description: '', + enabled: true, + scoreFunction: { + expression: 'f * 2', + variables: [ + { + name: 'F', + symbol: 'f', + source: 'factor' as const, + factorName: 'F', + }, + ], + }, + }, + ], + dataPoints: [], + results: { + ...emptyExperiment.results, + next: [[3]], + }, + } + + it('gives a copied suggestion a responses entry and a computed score for a factor-only function', () => { + const state = rootReducer({ experiment: baseExperiment } as State, { + type: 'copySuggestedToDataPoints', + payload: { indices: [0], removeFromSuggestions: false }, + }) + const dp = state.experiment.dataPoints[0] + expect(dp?.responses).toEqual([ + { scoreName: 'quality', useFunction: true, values: [] }, + ]) + expect( + dp?.data.find(d => d.type === 'score' && d.name === 'quality')?.value + ).toBe(6) + }) +}) + +describe('score-function factor sync', () => { + const withFn = { + ...emptyExperiment, + valueVariables: [ + { + type: 'discrete' as const, + name: 'Pin elevation', + description: '', + min: 0, + max: 200, + enabled: true, + }, + ], + scoreVariables: [ + { + name: 'quality' as const, + label: 'Quality (0-5)', + description: '', + enabled: true, + scoreFunction: { + expression: 'pinElevation', + variables: [ + { + name: 'Pin elevation', + symbol: 'pinElevation', + source: 'factor' as const, + factorName: 'Pin elevation', + }, + ], + }, + }, + ], + dataPoints: [], + } + + it('propagates a factor rename into the score function (symbol kept)', () => { + const s = rootReducer({ experiment: withFn } as State, { + type: 'editValueVariable', + payload: { + index: 0, + newVariable: { + type: 'discrete', + name: 'Pin height', + description: '', + min: 0, + max: 200, + enabled: true, + }, + }, + }) + const v = s.experiment.scoreVariables[0]?.scoreFunction?.variables[0] + expect(v).toMatchObject({ + factorName: 'Pin height', + name: 'Pin height', + symbol: 'pinElevation', + }) + }) + + it('prunes a deleted factor from the score function', () => { + const s = rootReducer({ experiment: withFn } as State, { + type: 'deleteValueVariable', + payload: 0, + }) + expect( + s.experiment.scoreVariables[0]?.scoreFunction?.variables + ).toHaveLength(0) + }) +}) diff --git a/packages/core/src/context/experiment/experiment-reducers.ts b/packages/core/src/context/experiment/experiment-reducers.ts index bed1df0f..1eb87406 100644 --- a/packages/core/src/context/experiment/experiment-reducers.ts +++ b/packages/core/src/context/experiment/experiment-reducers.ts @@ -4,6 +4,8 @@ import { ExperimentResultType, ExperimentType, OptimizerConfig, + ScoreFunctionType, + ScoreName, ScoreVariableType, ValueVariableType, experimentSchema, @@ -15,6 +17,7 @@ import { produce } from 'immer' import md5 from 'md5' import { settings, versionInfo } from '@core/common' import { assertUnreachable } from '@core/common/util' +import { computeScore } from '@core/common/util/score' import { selectActiveDataPoints, selectActiveDataPointsFromExperiment, @@ -175,6 +178,76 @@ export type ExperimentAction = type: 'experiment/removeVariableFromConstraintSum' payload: string } + | { + type: 'updateScoreFunction' + payload: { + scoreName: ScoreName + scoreFunction: ScoreFunctionType | undefined + } + } + | { + type: 'updateDataPointResponses' + payload: { + metaId: number + scoreName: ScoreName + useFunction: boolean + values: { symbol: string; value: number }[] + } + } + | { + type: 'setDataPointsUseFunction' + payload: { + scoreName: ScoreName + useFunction: boolean + } + } + +// Recompute and persist the {type:'score', name} value for one objective of one +// data entry, when that objective is in function mode. Manual rows are untouched. +// Canonical order of a data point's `data` entries (values, then categoricals, +// then scores) — the same order defaultSorted enforces. Score-entry order is +// positionally significant downstream (multi-objective yi columns, calculateXi, +// request hash), so a newly created score entry must be placed accordingly. +const dataEntryOrder = (state: ExperimentType): string[] => + state.valueVariables + .map(v => v.name) + .concat(state.categoricalVariables.map(v => v.name)) + .concat(state.scoreVariables.map(v => v.name)) + +const recomputeScore = ( + dataEntry: DataEntry, + scoreName: ScoreName, + scoreFunction: ScoreFunctionType | undefined, + orderedNames: string[] +): void => { + const responses = dataEntry.responses?.find(r => r.scoreName === scoreName) + if ( + scoreFunction === undefined || + responses === undefined || + !responses.useFunction + ) + return + const value = computeScore(scoreFunction, responses.values, dataEntry.data) + if (value === undefined) return + const scoreEntry = dataEntry.data.find( + d => d.type === 'score' && d.name === scoreName + ) + if (scoreEntry !== undefined) { + scoreEntry.value = value + } else { + // A new data point in function mode may have no score entry yet (the blank + // score column is dropped when converting the row). Create it so the + // computed value is persisted and the point validates. + dataEntry.data.push({ type: 'score', name: scoreName, value }) + // Restore canonical ordering so a score entered out of order (e.g. cost + // before quality) doesn't misalign the optimizer's score columns. + dataEntry.data.sort( + (a, b) => + orderedNames.findIndex(n => n === a.name) - + orderedNames.findIndex(n => n === b.name) + ) + } +} const experimentReducerInner = produce( (state: ExperimentType, action: ExperimentAction): void | ExperimentType => { @@ -245,6 +318,23 @@ const experimentReducerInner = produce( ) }), })) + // Default each copied point to using the score function, same as a + // table-added row (mirrors setDataPointsUseFunction). + const orderedNames = dataEntryOrder(state) + newEntries.forEach(entry => { + state.scoreVariables.forEach(sv => { + // Only enabled objectives, matching the table-add default path. + if (sv.enabled && sv.scoreFunction !== undefined) { + if (entry.responses === undefined) entry.responses = [] + entry.responses.push({ + scoreName: sv.name, + useFunction: true, + values: [], + }) + recomputeScore(entry, sv.name, sv.scoreFunction, orderedNames) + } + }) + }) state.dataPoints.push( ...defaultSorted( state.valueVariables, @@ -314,6 +404,14 @@ const experimentReducerInner = produce( oldVariable.name, action.payload.newVariable.name ) + state.scoreVariables.forEach(sv => + sv.scoreFunction?.variables.forEach(v => { + if (v.source === 'factor' && v.factorName === oldVariable.name) { + v.factorName = action.payload.newVariable.name + v.name = action.payload.newVariable.name + } + }) + ) } break } @@ -334,6 +432,16 @@ const experimentReducerInner = produce( d => d !== oldValueVariables[action.payload]?.name ), })) + const deletedName = oldValueVariables[action.payload]?.name + if (deletedName !== undefined) { + state.scoreVariables.forEach(sv => { + if (sv.scoreFunction !== undefined) { + sv.scoreFunction.variables = sv.scoreFunction.variables.filter( + v => !(v.source === 'factor' && v.factorName === deletedName) + ) + } + }) + } break } case 'setValueVariableEnabled': { @@ -492,6 +600,88 @@ const experimentReducerInner = produce( break } + case 'updateScoreFunction': { + const sv = state.scoreVariables.find( + it => it.name === action.payload.scoreName + ) + if (sv !== undefined) { + sv.scoreFunction = action.payload.scoreFunction + const orderedNames = dataEntryOrder(state) + state.dataPoints.forEach(dp => + recomputeScore( + dp, + action.payload.scoreName, + action.payload.scoreFunction, + orderedNames + ) + ) + } + break + } + case 'updateDataPointResponses': { + const dp = state.dataPoints.find( + d => d.meta.id === action.payload.metaId + ) + if (dp !== undefined) { + if (dp.responses === undefined) dp.responses = [] + const existing = dp.responses.find( + r => r.scoreName === action.payload.scoreName + ) + const next = { + scoreName: action.payload.scoreName, + useFunction: action.payload.useFunction, + values: action.payload.values, + } + if (existing !== undefined) { + existing.useFunction = next.useFunction + existing.values = next.values + } else { + dp.responses.push(next) + } + const sv = state.scoreVariables.find( + it => it.name === action.payload.scoreName + ) + recomputeScore( + dp, + action.payload.scoreName, + sv?.scoreFunction, + dataEntryOrder(state) + ) + } + break + } + case 'setDataPointsUseFunction': { + const sv = state.scoreVariables.find( + it => it.name === action.payload.scoreName + ) + if (sv?.scoreFunction !== undefined) { + const orderedNames = dataEntryOrder(state) + state.dataPoints.forEach(dp => { + const existing = dp.responses?.find( + r => r.scoreName === action.payload.scoreName + ) + if (existing !== undefined) { + existing.useFunction = action.payload.useFunction + } else if (action.payload.useFunction) { + // Only create an entry when turning the function ON; a row with no + // responses entry is already manual, so disabling is a no-op for it. + if (dp.responses === undefined) dp.responses = [] + dp.responses.push({ + scoreName: action.payload.scoreName, + useFunction: true, + values: [], + }) + } + recomputeScore( + dp, + action.payload.scoreName, + sv.scoreFunction, + orderedNames + ) + }) + } + break + } default: assertUnreachable(action) } diff --git a/packages/core/src/context/experiment/reducers.test.ts b/packages/core/src/context/experiment/reducers.test.ts index fbecd6e7..2ed3a9d1 100644 --- a/packages/core/src/context/experiment/reducers.test.ts +++ b/packages/core/src/context/experiment/reducers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { ExperimentAction } from './experiment-reducers' +import { ExperimentAction, experimentReducer } from './experiment-reducers' import { rootReducer } from './reducers' import { currentVersion, @@ -7,6 +7,7 @@ import { ExperimentResultType, ExperimentType, OptimizerConfig, + ScoreFunctionType, scoreNames, ValueVariableType, } from '@core/common/types' @@ -1435,3 +1436,166 @@ describe('resetting suggestion count when the model is first fit (#1)', () => { expect('experimentSuggestionCount' in after.extras).toBe(false) }) }) + +describe('updateScoreFunction + updateDataPointResponses', () => { + const fn: ScoreFunctionType = { + expression: 'weight * 2', + variables: [{ name: 'Weight', symbol: 'weight', source: 'response' }], + } + + // v21 experiment: one valueVariable, scoreVariables: [{name:'quality', ...}] + // (from emptyExperiment), one dataPoint with a quality score of 0 whose + // responses entry drives the function. + const buildState = (): ExperimentType => + produce(emptyExperiment, draft => { + draft.valueVariables = [createValueVariable({ name: 'Weight' })] + draft.dataPoints = [ + { + meta: { enabled: true, valid: true, id: 1 }, + data: [ + { type: 'numeric', name: 'Weight', value: 1.5 }, + { type: 'score', name: scoreNames[0], value: 0 }, + ], + responses: [ + { + scoreName: scoreNames[0], + useFunction: true, + values: [{ symbol: 'weight', value: 1.5 }], + }, + ], + }, + ] + }) + + const buildStateWithFunction = ( + scoreFunction: ScoreFunctionType + ): ExperimentType => + produce(buildState(), draft => { + const sv = draft.scoreVariables.find(s => s.name === scoreNames[0]) + if (sv !== undefined) sv.scoreFunction = scoreFunction + }) + + it('setting a function recomputes rows that use it', () => { + let state = buildState() + state = experimentReducer(state, { + type: 'updateScoreFunction', + payload: { scoreName: 'quality', scoreFunction: fn }, + }) + const score = state.dataPoints[0]?.data.find( + d => d.type === 'score' && d.name === 'quality' + ) + expect(score?.value).toBe(3) // 1.5 * 2 + expect(state.scoreVariables[0]?.scoreFunction).toEqual(fn) + }) + + it('updating responses recomputes that row when useFunction is true', () => { + let state: ExperimentType = buildStateWithFunction(fn) + state = experimentReducer(state, { + type: 'updateDataPointResponses', + payload: { + metaId: 1, + scoreName: 'quality', + useFunction: true, + values: [{ symbol: 'weight', value: 2 }], + }, + }) + const score = state.dataPoints[0]?.data.find( + d => d.type === 'score' && d.name === 'quality' + ) + expect(score?.value).toBe(4) + expect(state.dataPoints[0]?.responses?.[0]?.values[0]?.value).toBe(2) + }) + + it('creates a score entry when the row has none yet (new function-mode point)', () => { + // a new point: factor data present, function mode, but no score entry + // (the blank score column is dropped when the row is converted) + let state: ExperimentType = produce(buildStateWithFunction(fn), draft => { + const dp = draft.dataPoints[0] + if (dp !== undefined) { + dp.data = [{ type: 'numeric', name: 'Weight', value: 3 }] + } + }) + state = experimentReducer(state, { + type: 'updateDataPointResponses', + payload: { + metaId: 1, + scoreName: 'quality', + useFunction: true, + values: [{ symbol: 'weight', value: 3 }], + }, + }) + const score = state.dataPoints[0]?.data.find( + d => d.type === 'score' && d.name === 'quality' + ) + expect(score?.value).toBe(6) // 3 * 2, entry created from scratch + }) + + it('inserts a created score entry in canonical order (before a later objective)', () => { + // multi-objective: quality + cost both exist as score variables; the point + // has a cost score but no quality score, with cost currently first + let state: ExperimentType = produce(buildStateWithFunction(fn), draft => { + if (!draft.scoreVariables.some(s => s.name === scoreNames[1])) { + draft.scoreVariables.push({ + name: scoreNames[1], + label: 'Cost', + description: '', + enabled: true, + }) + } + const dp = draft.dataPoints[0] + if (dp !== undefined) { + dp.data = [ + { type: 'numeric', name: 'Weight', value: 3 }, + { type: 'score', name: scoreNames[1], value: 2 }, + ] + } + }) + state = experimentReducer(state, { + type: 'updateDataPointResponses', + payload: { + metaId: 1, + scoreName: 'quality', + useFunction: true, + values: [{ symbol: 'weight', value: 3 }], + }, + }) + const scoreOrder = state.dataPoints[0]?.data + .filter(d => d.type === 'score') + .map(d => d.name) + // quality (scoreNames[0]) must precede cost (scoreNames[1]), not appended after + expect(scoreOrder).toEqual([scoreNames[0], scoreNames[1]]) + }) + + it('does not recompute a row in manual mode', () => { + // a manually-entered score of 9 + let state: ExperimentType = produce(buildStateWithFunction(fn), draft => { + const score = draft.dataPoints[0]?.data.find( + d => d.type === 'score' && d.name === 'quality' + ) + if (score !== undefined) score.value = 9 + }) + // put the row in manual mode (useFunction false) + state = experimentReducer(state, { + type: 'updateDataPointResponses', + payload: { + metaId: 1, + scoreName: 'quality', + useFunction: false, + values: [{ symbol: 'weight', value: 2 }], + }, + }) + // changing the function must NOT overwrite the manual score. If manual mode + // were ignored, recompute would run weight*100 = 200; the value must stay 9. + state = experimentReducer(state, { + type: 'updateScoreFunction', + payload: { + scoreName: 'quality', + scoreFunction: { ...fn, expression: 'weight * 100' }, + }, + }) + const score = state.dataPoints[0]?.data.find( + d => d.type === 'score' && d.name === 'quality' + ) + expect(score?.value).toBe(9) + }) +}) diff --git a/packages/core/src/context/experiment/reducers.ts b/packages/core/src/context/experiment/reducers.ts index eb101be9..480b7cee 100644 --- a/packages/core/src/context/experiment/reducers.ts +++ b/packages/core/src/context/experiment/reducers.ts @@ -37,7 +37,10 @@ export const rootReducer = (state: State, action: Action) => { case 'experiment/toggleMultiObjective': case 'experiment/setConstraintSum': case 'experiment/addVariableToConstraintSum': - case 'experiment/removeVariableFromConstraintSum': { + case 'experiment/removeVariableFromConstraintSum': + case 'updateScoreFunction': + case 'updateDataPointResponses': + case 'setDataPointsUseFunction': { const experiment = experimentReducer(state.experiment, action) const validationViolations: ValidationViolations = validateExperiment(experiment) diff --git a/packages/core/src/context/experiment/test-utils.ts b/packages/core/src/context/experiment/test-utils.ts index 87fb1006..f37bb35b 100644 --- a/packages/core/src/context/experiment/test-utils.ts +++ b/packages/core/src/context/experiment/test-utils.ts @@ -140,4 +140,15 @@ export const dummyPayloads: Payloads = { 'experiment/setConstraintSum': 0, 'experiment/addVariableToConstraintSum': '', 'experiment/removeVariableFromConstraintSum': '', + updateScoreFunction: { scoreName: scoreNames[0], scoreFunction: undefined }, + updateDataPointResponses: { + metaId: 1, + scoreName: scoreNames[0], + useFunction: false, + values: [], + }, + setDataPointsUseFunction: { + scoreName: scoreNames[0], + useFunction: false, + }, } diff --git a/packages/core/src/context/experiment/validation-reducer.test.ts b/packages/core/src/context/experiment/validation-reducer.test.ts index 1fc34f0a..9cfbedba 100644 --- a/packages/core/src/context/experiment/validation-reducer.test.ts +++ b/packages/core/src/context/experiment/validation-reducer.test.ts @@ -46,6 +46,8 @@ const emptyViolations = { duplicateDataPointIds: [], categoricalValues: [], dataPointsNumericType: [], + dataPointsResponsesUndefined: [], + dataPointsScoreUncomputable: [], } satisfies ValidationViolations describe('validationReducer', () => { @@ -152,4 +154,13 @@ describe('validationReducer', () => { expect(validatedExperiment.dataPoints[0]?.meta.valid).toBeFalsy() expect(validatedExperiment.dataPoints[1]?.meta.valid).toBeTruthy() }) + + it('should invalidate data points with missing score function responses', () => { + const validatedExperiment = validationReducer(exp, { + ...emptyViolations, + dataPointsResponsesUndefined: [{ id: 1, scoreName: 'quality' }], + }) + expect(validatedExperiment.dataPoints[0]?.meta.valid).toBeFalsy() + expect(validatedExperiment.dataPoints[1]?.meta.valid).toBeTruthy() + }) }) diff --git a/packages/core/src/context/experiment/validation-reducer.ts b/packages/core/src/context/experiment/validation-reducer.ts index 02a8cb00..c1de87d7 100644 --- a/packages/core/src/context/experiment/validation-reducer.ts +++ b/packages/core/src/context/experiment/validation-reducer.ts @@ -14,7 +14,11 @@ export const validationReducer = produce( !violations.duplicateDataPointIds.includes(dp.meta.id) && !violations.categoricalValues.includes(dp.meta.id) && violations.duplicateVariableNames.length === 0 && - !violations.dataPointsNumericType.includes(dp.meta.id) + !violations.dataPointsNumericType.includes(dp.meta.id) && + !violations.dataPointsResponsesUndefined.some( + v => v.id === dp.meta.id + ) && + !violations.dataPointsScoreUncomputable.some(v => v.id === dp.meta.id) return { ...dp, meta: { diff --git a/packages/core/src/context/experiment/validation.test.ts b/packages/core/src/context/experiment/validation.test.ts index 608bdc85..946e61e4 100644 --- a/packages/core/src/context/experiment/validation.test.ts +++ b/packages/core/src/context/experiment/validation.test.ts @@ -8,10 +8,13 @@ import { validateDataPointsUndefined, validateDuplicateDataPointIds, validateDuplicateVariableNames, + validateExperiment, validateLowerBoundary, validateUpperBoundary, } from './validation' -import { ExperimentType, scoreNames } from '@core/common' +import { ExperimentType, ScoreFunctionType, scoreNames } from '@core/common' +import { countDataPointsMissingResponses } from './validation' +import { validationReducer } from './validation-reducer' describe('validateUpperBoundary', () => { it('should return empty array if no violations exist', () => { @@ -669,6 +672,8 @@ describe('findDataPointViolations', () => { upperBoundary: [1, 2, 5, 6], categoricalValues: [], dataPointsNumericType: [3], + dataPointsResponsesUndefined: [], + dataPointsScoreUncomputable: [], } it('should return correct list of data point violations', () => { const dpViolations = findDataPointViolations(violations) @@ -716,3 +721,148 @@ describe('findDataPointViolations', () => { ) }) }) + +const buildExperimentWithQualityFunction = ( + fn: ScoreFunctionType +): ExperimentType => ({ + ...emptyExperiment, + scoreVariables: emptyExperiment.scoreVariables.map(sv => + sv.name === scoreNames[0] ? { ...sv, scoreFunction: fn } : sv + ), + dataPoints: [ + { + meta: { + id: 1, + enabled: true, + valid: true, + }, + data: [], + responses: [ + { + scoreName: scoreNames[0], + useFunction: true, + values: [], + }, + ], + }, + ], +}) + +describe('response validation', () => { + const base = () => + buildExperimentWithQualityFunction({ + expression: 'weight * 2', + variables: [{ name: 'Weight', symbol: 'weight', source: 'response' }], + }) + + it('flags a row using the function with a missing response value', () => { + const exp = base() // dataPoint meta.id 1, responses useFunction:true but values:[] + const v = validateExperiment(exp) + expect( + v.dataPointsResponsesUndefined.some( + x => x.id === 1 && x.scoreName === 'quality' + ) + ).toBe(true) + const messages = + findDataPointViolations(v).find(x => x.rowMetaId === 1)?.messages ?? [] + expect(messages).toContain( + 'All responses must be defined to use the quality function.' + ) + }) + + it('does not flag a row in manual mode', () => { + const exp = base() + exp.dataPoints[0]!.responses = [ + { scoreName: 'quality', useFunction: false, values: [] }, + ] + expect( + validateExperiment(exp).dataPointsResponsesUndefined.some(x => x.id === 1) + ).toBe(false) + }) +}) + +describe('countDataPointsMissingResponses', () => { + const fn = { + expression: 'w * 2', + variables: [{ name: 'W', symbol: 'w', source: 'response' as const }], + } + const complete = { + meta: { id: 1, enabled: true, valid: true }, + data: [{ type: 'score' as const, name: 'quality', value: 0 }], + responses: [ + { + scoreName: 'quality' as const, + useFunction: false, + values: [{ symbol: 'w', value: 2 }], + }, + ], + } + const missing = { + meta: { id: 2, enabled: true, valid: true }, + data: [{ type: 'score' as const, name: 'quality', value: 0 }], + } + + it('counts data points lacking the function’s required responses (ignoring current useFunction)', () => { + expect( + countDataPointsMissingResponses([complete, missing], 'quality', fn) + ).toBe(1) + }) + + it('returns 0 when there is no score function', () => { + expect( + countDataPointsMissingResponses([missing], 'quality', undefined) + ).toBe(0) + }) +}) + +it('flags a function-mode point as invalid when a used factor is unavailable', () => { + const experiment = { + ...emptyExperiment, + valueVariables: [ + // "Pin elevation" is disabled -> not present in the data point's data + { + type: 'discrete' as const, + name: 'Pin elevation', + description: '', + min: 0, + max: 200, + enabled: false, + }, + ], + scoreVariables: [ + { + name: 'quality' as const, + label: 'Quality (0-5)', + description: '', + enabled: true, + scoreFunction: { + expression: 'pinElevation', + variables: [ + { + name: 'Pin elevation', + symbol: 'pinElevation', + source: 'factor' as const, + factorName: 'Pin elevation', + }, + ], + }, + }, + ], + dataPoints: [ + { + meta: { id: 1, enabled: true, valid: true }, + data: [{ type: 'score' as const, name: 'quality', value: 3 }], // no Pin elevation column + responses: [ + { scoreName: 'quality' as const, useFunction: true, values: [] }, + ], + }, + ], + } + const violations = validateExperiment(experiment) + expect(violations.dataPointsScoreUncomputable).toContainEqual({ + id: 1, + scoreName: 'quality', + }) + const validated = validationReducer(experiment, violations) + expect(validated.dataPoints[0]?.meta.valid).toBe(false) +}) diff --git a/packages/core/src/context/experiment/validation.ts b/packages/core/src/context/experiment/validation.ts index 02235307..c1f28818 100644 --- a/packages/core/src/context/experiment/validation.ts +++ b/packages/core/src/context/experiment/validation.ts @@ -1,4 +1,5 @@ -import { ExperimentType } from 'common' +import { DataEntry, ExperimentType, ScoreFunctionType } from 'common' +import { computeScore } from '@core/common/util/score' export type ValidationViolations = { upperBoundary: number[] @@ -8,6 +9,8 @@ export type ValidationViolations = { duplicateDataPointIds: number[] categoricalValues: number[] dataPointsNumericType: number[] + dataPointsResponsesUndefined: { id: number; scoreName: string }[] + dataPointsScoreUncomputable: { id: number; scoreName: string }[] } export const validateExperiment = ( @@ -21,6 +24,10 @@ export const validateExperiment = ( duplicateDataPointIds: validateDuplicateDataPointIds(experiment), categoricalValues: validateCategoricalValues(experiment), dataPointsNumericType: validateDataPointsNumericType(experiment), + dataPointsResponsesUndefined: + validateDataPointsResponsesUndefined(experiment), + dataPointsScoreUncomputable: + validateDataPointsScoreUncomputable(experiment), } } @@ -141,6 +148,83 @@ export const validateCategoricalValues = (experiment: ExperimentType) => { return violations } +// Are all of the function's response-source symbols present & finite in `values`? +const responsesComplete = ( + scoreFunction: ScoreFunctionType, + values: { symbol: string; value: number }[] +): boolean => { + const provided = new Map(values.map(v => [v.symbol, v.value])) + return scoreFunction.variables + .filter(v => v.source === 'response') + .every(v => { + const val = provided.get(v.symbol) + return val !== undefined && Number.isFinite(val) + }) +} + +// How many data points would be invalid if `scoreFunction` were used for +// `scoreName` (i.e. are missing its required responses), regardless of current +// useFunction. Pass the draft function so the warning reflects what Save applies. +export const countDataPointsMissingResponses = ( + dataPoints: DataEntry[], + scoreName: string, + scoreFunction: ScoreFunctionType | undefined +): number => { + if (scoreFunction === undefined) return 0 + return dataPoints.filter(dp => { + const resp = dp.responses?.find(r => r.scoreName === scoreName) + return !responsesComplete(scoreFunction, resp?.values ?? []) + }).length +} + +export const validateDataPointsResponsesUndefined = ( + experiment: ExperimentType +): { id: number; scoreName: string }[] => { + const violations: { id: number; scoreName: string }[] = [] + const functionsByScore = new Map( + experiment.scoreVariables + .filter(sv => sv.scoreFunction !== undefined) + .map(sv => [sv.name, sv.scoreFunction!]) + ) + experiment.dataPoints.forEach(dp => { + dp.responses?.forEach(resp => { + const fn = functionsByScore.get(resp.scoreName) + if (fn === undefined || !resp.useFunction) return + if (!responsesComplete(fn, resp.values)) { + violations.push({ id: dp.meta.id, scoreName: resp.scoreName }) + } + }) + }) + return violations +} + +// Function-mode points whose responses are complete but whose score still can't +// be computed — e.g. a factor the function uses is disabled/removed, or the +// expression is invalid. (Responses-missing is reported separately.) +export const validateDataPointsScoreUncomputable = ( + experiment: ExperimentType +): { id: number; scoreName: string }[] => { + const violations: { id: number; scoreName: string }[] = [] + const functionsByScore = new Map( + experiment.scoreVariables + .filter(sv => sv.scoreFunction !== undefined) + .map(sv => [sv.name, sv.scoreFunction!]) + ) + experiment.dataPoints.forEach(dp => { + dp.responses?.forEach(resp => { + const fn = functionsByScore.get(resp.scoreName) + if (fn === undefined || !resp.useFunction) return + if ( + responsesComplete(fn, resp.values) && + computeScore(fn, resp.values, dp.data) === undefined + ) { + violations.push({ id: dp.meta.id, scoreName: resp.scoreName }) + } + }) + }) + return violations +} + export const findUniqueEntries = (arr: number[]): number[] => arr.filter((val, i, arr) => arr.indexOf(val) === i) @@ -160,11 +244,15 @@ export const findDataPointViolations = ( const upperBoundary = violations.upperBoundary const lowerBoundary = violations.lowerBoundary const numericType = violations.dataPointsNumericType + const responsesUndefined = violations.dataPointsResponsesUndefined + const scoreUncomputable = violations.dataPointsScoreUncomputable findUniqueEntries( pointsUndefined .concat(upperBoundary) .concat(lowerBoundary) .concat(numericType) + .concat(responsesUndefined.map(r => r.id)) + .concat(scoreUncomputable.map(r => r.id)) ).forEach(e => { const messages: string[] = [] if (pointsUndefined.includes(e)) { @@ -185,6 +273,20 @@ export const findDataPointViolations = ( if (numericType.includes(e)) { messages.push('Discrete values must be integers.') } + responsesUndefined + .filter(r => r.id === e) + .forEach(r => { + messages.push( + `All responses must be defined to use the ${r.scoreName} function.` + ) + }) + scoreUncomputable + .filter(r => r.id === e) + .forEach(r => { + messages.push( + `The ${r.scoreName} function can't be computed for this point — check the factors it uses and its expression.` + ) + }) allViolations.push({ rowMetaId: e, messages, diff --git a/packages/ui/package.json b/packages/ui/package.json index 501cce8f..92673ebc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -8,6 +8,7 @@ "@boostv/process-optimizer-frontend-core": "2.16.1", "@boostv/process-optimizer-frontend-plots": "3.0.2", "lodash.debounce": "^4.0.8", + "mathjs": "^14.8.0", "react-hook-form": "^7.76.1", "remeda": "^2.37.0", "tss-react": "^4.9.21" diff --git a/packages/ui/src/common/forms/highlight-field.test.tsx b/packages/ui/src/common/forms/highlight-field.test.tsx new file mode 100644 index 00000000..52f32d36 --- /dev/null +++ b/packages/ui/src/common/forms/highlight-field.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { createRef } from 'react' +import { render, screen, cleanup, fireEvent } from '@testing-library/react' +import { HighlightField, type HighlightFieldHandle } from './highlight-field' + +afterEach(() => cleanup()) + +// highlight that wraps the token "x" in a coloured span, escaping the rest. +const highlight = (code: string) => + code.replace(/x|[^x]+/g, t => + t === 'x' ? `x` : t + ) + +describe('HighlightField', () => { + it('renders the highlighted overlay for the value', () => { + const { container } = render( + {}} highlight={highlight} /> + ) + const pre = container.querySelector('pre') + expect(pre).toHaveTextContent('x+1') + // the recognised token is wrapped in a coloured span + expect(pre?.querySelector('span')).toHaveTextContent('x') + }) + + it('shows the placeholder when empty', () => { + render( + {}} + highlight={highlight} + placeholder="type here" + /> + ) + expect(screen.getByText('type here')).toBeInTheDocument() + }) + + it('calls onChange when edited', () => { + const onChange = vi.fn() + render( + + ) + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'x' } }) + expect(onChange).toHaveBeenCalledWith('x') + }) + + it('insertAtCursor splices text at the caret', () => { + const onChange = vi.fn() + const ref = createRef() + render( + + ) + const textarea = screen.getByRole('textbox') as HTMLTextAreaElement + textarea.setSelectionRange(1, 1) // caret between a and c + ref.current?.insertAtCursor('b') + expect(onChange).toHaveBeenCalledWith('abc') + }) +}) diff --git a/packages/ui/src/common/forms/highlight-field.tsx b/packages/ui/src/common/forms/highlight-field.tsx new file mode 100644 index 00000000..6e7e2dc0 --- /dev/null +++ b/packages/ui/src/common/forms/highlight-field.tsx @@ -0,0 +1,153 @@ +import { forwardRef, useImperativeHandle, useRef } from 'react' + +export type HighlightFieldHandle = { + /** Insert text at the caret (replacing any selection) and keep focus. */ + insertAtCursor: (text: string) => void +} + +type Props = { + value: string + onChange: (next: string) => void + onBlur?: () => void + /** + * Returns the HTML to render as the coloured layer for `code`. + * IMPORTANT: the implementation MUST escape any HTML in the input, since the + * result is injected via dangerouslySetInnerHTML. + */ + highlight: (code: string) => string + placeholder?: string + minHeight?: number + maxHeight?: number + paddingRight?: number + textareaId?: string + ariaLabel?: string +} + +// The transparent