(null)
+
+ useImperativeHandle(ref, () => ({
+ insertAtCursor: (text: string) => {
+ const ta = textareaRef.current
+ const start = ta?.selectionStart ?? value.length
+ const end = ta?.selectionEnd ?? value.length
+ onChange(value.slice(0, start) + text + value.slice(end))
+ // Restore the caret just after the inserted text once React re-renders.
+ const restoreCaret = () => {
+ const el = textareaRef.current
+ if (el !== null) {
+ const pos = start + text.length
+ el.focus()
+ el.setSelectionRange(pos, pos)
+ }
+ }
+ if (typeof requestAnimationFrame === 'function') {
+ requestAnimationFrame(restoreCaret)
+ }
+ },
+ }))
+
+ // Empty → show the placeholder greyed out (the textarea's own text is
+ // transparent, so its native placeholder wouldn't render reliably). A
+ // trailing newline needs an extra line so the two layers stay the same
+ // height.
+ const html =
+ value === ''
+ ? `${escapeHtml(
+ placeholder ?? ''
+ )}`
+ : highlight(value) + (value.endsWith('\n') ? '
' : '')
+
+ return (
+
+ )
+ }
+)
+HighlightField.displayName = 'HighlightField'
diff --git a/packages/ui/src/common/forms/index.ts b/packages/ui/src/common/forms/index.ts
index 4adc63b5..fd389bbd 100644
--- a/packages/ui/src/common/forms/index.ts
+++ b/packages/ui/src/common/forms/index.ts
@@ -2,3 +2,4 @@ export * from './form-input'
export * from './form-radio-group'
export * from './validation'
export * from './rating-input'
+export * from './highlight-field'
diff --git a/packages/ui/src/features/data-points/settings/score-function-field.test.tsx b/packages/ui/src/features/data-points/settings/score-function-field.test.tsx
index 685faf7a..6dea5b30 100644
--- a/packages/ui/src/features/data-points/settings/score-function-field.test.tsx
+++ b/packages/ui/src/features/data-points/settings/score-function-field.test.tsx
@@ -13,9 +13,7 @@ describe('ScoreFunctionField', () => {
onChange={() => {}}
/>
)
- // Scoped to the highlighted : react-simple-code-editor also mounts the
- // raw value as the textarea's initial text node, so an unscoped
- // screen.getByText(/weight/) matches both and throws "multiple elements".
+ // The coloured text lives in the highlighted overlay.
expect(container.querySelector('pre')).toHaveTextContent('weight')
})
diff --git a/packages/ui/src/features/data-points/settings/score-function-field.tsx b/packages/ui/src/features/data-points/settings/score-function-field.tsx
index 9bead17c..b045ec35 100644
--- a/packages/ui/src/features/data-points/settings/score-function-field.tsx
+++ b/packages/ui/src/features/data-points/settings/score-function-field.tsx
@@ -1,18 +1,7 @@
-import { forwardRef, useImperativeHandle, useRef } from 'react'
-import EditorImport from 'react-simple-code-editor'
+import { forwardRef } from 'react'
+import { HighlightField, type HighlightFieldHandle } from '@ui/common'
-// `react-simple-code-editor` is a CommonJS module (`exports.default = Editor`).
-// Some bundler interop paths (notably Vite's optimized deps) surface the default
-// import wrapped as `{ default: Component }` instead of the component itself,
-// which makes React throw "Element type is invalid". Unwrap defensively so it
-// works whether the interop hands back the component or the module object.
-const Editor =
- (EditorImport as unknown as { default?: typeof EditorImport }).default ??
- EditorImport
-
-export type ScoreFunctionFieldHandle = {
- insertAtCursor: (text: string) => void
-}
+export type ScoreFunctionFieldHandle = HighlightFieldHandle
type Props = {
value: string
@@ -21,61 +10,33 @@ type Props = {
onBlur?: () => void
}
-// Split an expression into variable/other tokens and wrap recognised symbols in
-// a coloured span. Recognised = present in `symbols`. Everything else is plain.
-const highlight = (code: string, symbols: string[]): string => {
- const escape = (s: string) =>
- s.replace(/&/g, '&').replace(//g, '>')
- return code.replace(/[A-Za-z_][A-Za-z0-9_]*|[^A-Za-z_]+/g, token => {
- if (symbols.includes(token)) {
- return `${escape(token)}`
- }
- return escape(token)
- })
-}
-
-export const ScoreFunctionField = forwardRef(
- ({ value, symbols, onChange, onBlur }, ref) => {
- const lastSelection = useRef(value.length)
+const escapeHtml = (s: string) =>
+ s.replace(/&/g, '&').replace(//g, '>')
- useImperativeHandle(ref, () => ({
- insertAtCursor: (text: string) => {
- const pos = lastSelection.current ?? value.length
- onChange(value.slice(0, pos) + text + value.slice(pos))
- },
- }))
+// Wrap recognised symbols (present in `symbols`) in a coloured span; everything
+// else is escaped plain text.
+const highlightSymbols = (code: string, symbols: string[]): string =>
+ code.replace(/[A-Za-z_][A-Za-z0-9_]*|[^A-Za-z_]+/g, token =>
+ symbols.includes(token)
+ ? `${escapeHtml(token)}`
+ : escapeHtml(token)
+ )
- return (
- highlight(code, symbols)}
- padding={10}
- onKeyUp={e => {
- lastSelection.current =
- (e.target as HTMLTextAreaElement).selectionStart ?? value.length
- }}
- onClick={e => {
- lastSelection.current =
- (e.target as HTMLTextAreaElement).selectionStart ?? value.length
- }}
- onBlur={onBlur}
- placeholder="Enter function, e.g. a+b*c"
- textareaId="score-function-input"
- style={{
- fontFamily: 'monospace',
- fontSize: 16,
- border: '1px solid rgba(0,0,0,0.23)',
- borderRadius: 4,
- minHeight: 48,
- // Cap growth (~6 lines: 6×24px line + 2×10px padding) and scroll,
- // so a long expression doesn't grow into the playground beside it.
- maxHeight: 164,
- overflowY: 'auto',
- lineHeight: '24px',
- }}
- />
- )
- }
+export const ScoreFunctionField = forwardRef(
+ ({ value, symbols, onChange, onBlur }, ref) => (
+ highlightSymbols(code, symbols)}
+ placeholder="Enter function, e.g. a+b*c"
+ ariaLabel="score function"
+ textareaId="score-function-input"
+ minHeight={48}
+ // cap growth (~6 lines) and scroll rather than pushing the layout
+ maxHeight={164}
+ />
+ )
)
ScoreFunctionField.displayName = 'ScoreFunctionField'
diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts
index 1e266c9f..4e3d23f7 100644
--- a/packages/ui/vite.config.ts
+++ b/packages/ui/vite.config.ts
@@ -32,13 +32,6 @@ export default defineConfig({
'react/jsx-dev-runtime',
'react-dom',
'react-hook-form',
- // CJS-only dep: if bundled, rolldown rewrites its internal
- // `require("react")` to a runtime `__require`, which throws in an ESM
- // browser env ("Calling `require` ... doesn't expose the `require`
- // function"). Externalise it like the other deps so the consumer
- // resolves it (it's a declared dependency) and its bundler handles the
- // CJS↔ESM interop.
- 'react-simple-code-editor',
'@boostv/process-optimizer-frontend-api',
'@boostv/process-optimizer-frontend-core',
'@boostv/process-optimizer-frontend-plots',
From c1daa2546decdd181bd7403580ead833e8640510 Mon Sep 17 00:00:00 2001
From: Jack Ord
Date: Fri, 24 Jul 2026 09:19:42 +0000
Subject: [PATCH 31/60] =?UTF-8?q?docs(adr):=200003=20score=20functions=20?=
=?UTF-8?q?=E2=80=94=20additive=20data=20model=20persisting=20into=20the?=
=?UTF-8?q?=20score=20entry?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 4.8 (1M context)
---
...003-score-functions-additive-data-model.md | 64 +++++++++++++++++++
docs/adr/README.md | 9 +--
2 files changed, 69 insertions(+), 4 deletions(-)
create mode 100644 docs/adr/0003-score-functions-additive-data-model.md
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 |
From b2d166d26c6ddc1472186d86d1127d7556209215 Mon Sep 17 00:00:00 2001
From: Jack Ord
Date: Fri, 24 Jul 2026 09:29:27 +0000
Subject: [PATCH 32/60] =?UTF-8?q?chore(ui):=20address=20final=20review=20?=
=?UTF-8?q?=E2=80=94=20route=20quality-scale=20check=20via=20scoreNames,?=
=?UTF-8?q?=20drop=20dead=20code?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Gate the quality 0-5 InfoBox on scoreNames[0] instead of a bare 'quality'
literal (keeps the objective identity check in one place).
- Remove the leftover commented-out Result Typography block in the playground.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../features/data-points/settings/data-points-settings.tsx | 3 ++-
.../data-points/settings/score-function-playground.tsx | 4 ----
2 files changed, 2 insertions(+), 5 deletions(-)
diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.tsx b/packages/ui/src/features/data-points/settings/data-points-settings.tsx
index f866a413..9af09431 100644
--- a/packages/ui/src/features/data-points/settings/data-points-settings.tsx
+++ b/packages/ui/src/features/data-points/settings/data-points-settings.tsx
@@ -20,6 +20,7 @@ import {
useExperiment,
deriveSymbol,
usedSymbols,
+ scoreNames,
type ScoreFunctionType,
type ScoreFunctionVariableType,
} from '@boostv/process-optimizer-frontend-core'
@@ -222,7 +223,7 @@ export function DataPointsSettings({
Score function
- {activeScore?.name === 'quality' && (
+ {activeScore?.name === scoreNames[0] && (
{
value={result ?? ''}
sx={{ maxWidth: '10rem' }}
/>
- {/* Result
-
- {result === undefined ? '—' : result}
- */}
)
From c1618a5005e53bd240ca2e682f4dea7830b21aa6 Mon Sep 17 00:00:00 2001
From: Jack Ord
Date: Fri, 24 Jul 2026 11:32:55 +0000
Subject: [PATCH 33/60] test(core): strengthen manual-mode reducer assertion
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 'does not recompute a row in manual mode' test set no manual value and only
asserted .not.toBe(200), which passed merely because the score stayed 0. Now it
seeds a real manual score of 9 and asserts it is preserved exactly (toBe(9))
after the function changes — catching any recompute, not just the value 200.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../core/src/context/experiment/reducers.test.ts | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/packages/core/src/context/experiment/reducers.test.ts b/packages/core/src/context/experiment/reducers.test.ts
index 5ef214f5..2ed3a9d1 100644
--- a/packages/core/src/context/experiment/reducers.test.ts
+++ b/packages/core/src/context/experiment/reducers.test.ts
@@ -1567,8 +1567,14 @@ describe('updateScoreFunction + updateDataPointResponses', () => {
})
it('does not recompute a row in manual mode', () => {
- let state: ExperimentType = buildStateWithFunction(fn)
- // set manual value 9, useFunction false
+ // 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: {
@@ -1578,7 +1584,8 @@ describe('updateScoreFunction + updateDataPointResponses', () => {
values: [{ symbol: 'weight', value: 2 }],
},
})
- // changing the function should NOT overwrite the manual score
+ // 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: {
@@ -1589,6 +1596,6 @@ describe('updateScoreFunction + updateDataPointResponses', () => {
const score = state.dataPoints[0]?.data.find(
d => d.type === 'score' && d.name === 'quality'
)
- expect(score?.value).not.toBe(200)
+ expect(score?.value).toBe(9)
})
})
From dc8b128836373c02eee4cdb2294726b480f94b87 Mon Sep 17 00:00:00 2001
From: Jack Ord
Date: Tue, 28 Jul 2026 12:59:14 +0000
Subject: [PATCH 34/60] feat(ui): rename data-points Settings to Score
functions with a star icon
Renames the header tooltip and the settings-panel title from Settings to
Score functions, swaps the cog for a star icon (matching the quality rating
star in the editor), and drops the now-redundant 'Score function' subtitle
inside the tabs.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
packages/ui/src/features/data-points/data-points.tsx | 10 +++++-----
.../data-points/settings/data-points-settings.tsx | 7 +++----
2 files changed, 8 insertions(+), 9 deletions(-)
diff --git a/packages/ui/src/features/data-points/data-points.tsx b/packages/ui/src/features/data-points/data-points.tsx
index e32e7717..c7995040 100644
--- a/packages/ui/src/features/data-points/data-points.tsx
+++ b/packages/ui/src/features/data-points/data-points.tsx
@@ -2,8 +2,8 @@ import { CircularProgress, IconButton, Box, Tooltip } from '@mui/material'
import { EditableTable } from '../core'
import SwapVertIcon from '@mui/icons-material/SwapVert'
-import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'
-import SettingsIcon from '@mui/icons-material/Settings'
+import StarIcon from '@mui/icons-material/Star'
+import StarBorderIcon from '@mui/icons-material/StarBorder'
import { TitleCard } from '../core/title-card/title-card'
import DownloadCSVButton from './download-csv-button'
import useStyles from './data-points.style'
@@ -162,16 +162,16 @@ export function DataPoints(props: DataPointProps) {
-
+
setSettingsOpen(!isSettingsOpen)}
>
{isSettingsOpen ? (
-
+
) : (
-
+
)}
diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.tsx b/packages/ui/src/features/data-points/settings/data-points-settings.tsx
index 9af09431..b9a64236 100644
--- a/packages/ui/src/features/data-points/settings/data-points-settings.tsx
+++ b/packages/ui/src/features/data-points/settings/data-points-settings.tsx
@@ -11,7 +11,7 @@ import {
DialogActions,
Typography,
} from '@mui/material'
-import SettingsIcon from '@mui/icons-material/Settings'
+import StarIcon from '@mui/icons-material/Star'
import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined'
import { useMemo, useRef, useState } from 'react'
import { InfoBox } from '@ui/features/core'
@@ -158,8 +158,8 @@ export function DataPointsSettings({
return (
-
- Settings
+
+ Score functions
From f167f1b66a268e3e14f41d787555b3a349106d6d Mon Sep 17 00:00:00 2001
From: Jack Ord
Date: Tue, 28 Jul 2026 13:38:23 +0000
Subject: [PATCH 39/60] feat(ui): full-width 'For existing data points' section
with primary-fill toggle
Move the bulk score-function control below the two side-by-side columns so it
spans the panel's full width, and style the segmented toggle like the Brownie
Bee /settings toggles (selected option filled with the brand colour).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../settings/data-points-settings.style.ts | 20 ++++++
.../settings/data-points-settings.tsx | 65 ++++++++++---------
2 files changed, 53 insertions(+), 32 deletions(-)
diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.style.ts b/packages/ui/src/features/data-points/settings/data-points-settings.style.ts
index 030d8779..23ba8801 100644
--- a/packages/ui/src/features/data-points/settings/data-points-settings.style.ts
+++ b/packages/ui/src/features/data-points/settings/data-points-settings.style.ts
@@ -28,6 +28,26 @@ export const useStyles = makeStyles()(theme => ({
borderLeft: `1px solid ${theme.palette.divider}`,
paddingLeft: theme.spacing(2),
},
+ // Full-width section below the two side-by-side columns.
+ bulkContainer: {
+ borderTop: `1px solid ${theme.palette.divider}`,
+ paddingTop: theme.spacing(2),
+ marginBottom: theme.spacing(2),
+ },
+ // Segmented control: the selected option is filled with the brand colour
+ // (matching the Brownie Bee /settings toggles).
+ bulkToggle: {
+ '& .MuiToggleButton-root': {
+ textTransform: 'none',
+ },
+ '& .MuiToggleButton-root.Mui-selected': {
+ backgroundColor: theme.palette.primary.main,
+ color: theme.palette.primary.contrastText,
+ '&:hover': {
+ backgroundColor: theme.palette.primary.dark,
+ },
+ },
+ },
title: {
marginBottom: theme.spacing(2),
marginTop: theme.spacing(1),
diff --git a/packages/ui/src/features/data-points/settings/data-points-settings.tsx b/packages/ui/src/features/data-points/settings/data-points-settings.tsx
index c9c34e9d..dddff437 100644
--- a/packages/ui/src/features/data-points/settings/data-points-settings.tsx
+++ b/packages/ui/src/features/data-points/settings/data-points-settings.tsx
@@ -401,39 +401,40 @@ export function DataPointsSettings({
Test your function
+