diff --git a/AGENTS.md b/AGENTS.md index 3e969c33..a7955397 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ This repository contains `fl-platform`, the Angular 22 standalone frontend for t - `src/app/services/`: auth/session, experiment orchestration, dashboard data access, algorithm rules, labeling, runtime env, theme, errors, and PDF/CSV exports. - `src/app/models/`: frontend and backend DTOs/interfaces for users, algorithms, experiments, filters, and data models. - `src/app/core/`: algorithm mapping, result enum mapping, constants, and result utility logic. -- `src/app/pages/experiment-studio/`: step views switched via a sticky horizontal stepper — `experiment-studio.component` shows one step view at a time (Datasets & Variables · Data review & preprocessing · Algorithm); all views stay mounted (hidden via CSS) so state is preserved. On the Data review step the stepper expands a sub-step row (Filtering, Raw Summary, Preprocessing, Processed Summary, Transformation) that drives the `statistic-analysis-panel` sections (`goToSection`). In-panel section targets use `scroll-margin-top` so `scrollIntoView` clears sticky chrome. Also contains variable/filter selection, QueryBuilder filter UI, algorithm configuration with variable role assignment (outcome y / covariates x on the algorithm panel), run/edit flows, statistics, visualizations, and result rendering. +- `src/app/pages/experiment-studio/`: step views switched via a sticky horizontal stepper — `experiment-studio.component` shows one step view at a time (Datasets & Variables · Data review & preprocessing · Algorithm); all views stay mounted (hidden via CSS) so state is preserved. On the Data review step the stepper expands a sub-step row (Filtering, Raw Summary, Preprocessing, Processed Summary, Transformation) that drives the `statistic-analysis-panel` sections (`goToSection`). In-panel section targets use `scroll-margin-top` so `scrollIntoView` clears sticky chrome. Also contains variable/filter selection, QueryBuilder filter UI, algorithm configuration with variable role assignment (outcome y / covariates x on the algorithm panel; `algorithm-panel` is a shell over `algorithm-selection/` and `experiment-result/` sections that share panel-provided services), run/edit flows, statistics, visualizations, and result rendering. - `src/app/pages/experiments-dashboard/`: experiment list/search/pagination, detail view, compare mode, sharing, delete/edit/name updates, and result export. - `src/app/pages/terms-page/`: NDA/TOS display and acceptance flow. - `src/app/pages/account-page/`: account/profile view and logout entry. diff --git a/docs/context/conventions.md b/docs/context/conventions.md index ee621fc5..c5cf94eb 100644 --- a/docs/context/conventions.md +++ b/docs/context/conventions.md @@ -31,6 +31,7 @@ - Use component CSS for feature-specific styles. - Use `src/styles.css` for app-wide styles and QueryBuilder theming. - A component may list several stylesheets in `styleUrls`; Angular concatenates them **in array order**, so that order is the cascade. Splitting a file must preserve the original order and must not move a rule across another rule that shares its selector — `statistic-analysis-panel` is split this way (`.component.css` shell + pipeline canvas, then `.preprocessing.css`, then `.results.css`). +- A component split into section components keeps its DOM by attaching each section to the element it renders into (attribute selectors such as `section[app-algorithm-parameters]`). Each rule moves to the stylesheet of the template its element is declared in; a rule that reaches into a section from its host element is rewritten as `:host(.class)` (same specificity as `.class` under emulated encapsulation). Rules several sections share go into `styles/*.shared.css` partials, `@import`ed at the top of each stylesheet, so a rule only moves into a partial when it does not cross an equal-specificity rule for the same element (`algorithm-panel/` keeps its `.ng-touched` validation rules inline for that reason). - Consult `DESIGN.md` before visual changes. - Existing branding assets live under `src/assets`. diff --git a/docs/context/decision-log.md b/docs/context/decision-log.md index 1e7e1f92..d0c1403c 100644 --- a/docs/context/decision-log.md +++ b/docs/context/decision-log.md @@ -215,3 +215,17 @@ Consequences: Breaking: this UI needs a backend with PR #34 and does not work ag Files affected: `src/app/services/experiment-studio.service.ts`, `src/app/services/experiment-studio.service.spec.ts`, `e2e/experiment-studio-api.ts`, `e2e/experiment-studio.flows.spec.ts`, `README.md`, `docs/context/*.md`, `docs/llm-wiki/indexes/backend-endpoints.md`. Date: 2026-10-15. + +### Decision: Algorithm panel split into selection and result sections + +Status: Accepted + +Context: `algorithm-panel.component.ts` had grown to 1,703 lines with 28 `any`, loose mutable fields (`lastUsedAlgorithm`, the outlier_report rule maps, `formKey`, the built-form signature, the category-collapse bookkeeping) and one 22.1 kB stylesheet over the 20 kB component budget. Data Handling had been split the same way (PR 56). + +Decision: `AlgorithmPanelComponent` (`app-algorithm-panel`) stays the studio's entry point with the same API (`canRun`, `runDisabledReason`, `onClickRunExp`, `setStudioSubstep`, `studioSubstepChange`, `onExportResult`, `onExportResultZip`) and provides four component-scoped services: `AlgorithmCatalogService` (search, Runnable/All, group collapse as a `linkedSignal`, availability text), `AlgorithmConfigFormService` (enriched schema, reactive form, cross-validation, positive_class, outlier_report rules as signals), `AlgorithmRunService` (run needs, `canRun`, the run, its error, PDF/ZIP export) and `AlgorithmStudioSubstepService`. Its template is a shell over `section[app-algorithm-selection]` (role assignment host, `aside[app-algorithm-catalog]`, details, `section[app-algorithm-parameters]` with `div[app-algorithm-config-field]`, `section[app-outlier-report-editor]`), the projected `div[app-experiment-result]` readiness bar with `ul[app-algorithm-run-requirements]`. Attribute selectors keep the DOM, the `data-guide` anchors, `#algo-search`, sessionStorage keys and request payloads unchanged; pure helpers live in `algorithm-panel/utils/` with specs and shared types in `algorithm-panel.models.ts`. + +Consequences: Every stylesheet is under budget (the panel's is 3.3 kB). The outlier_report editor is still separate from Data Handling's outlier step; sharing one editor is a follow-up once PR 56 lands. The catalog's availability tooltip (`showTooltip` had no caller) was removed with its position helper and `formatInputCountRange`. `algorithm-result/` is shared with the Execution step and the dashboard and is unchanged. + +Files affected: `src/app/pages/experiment-studio/algorithm-panel/**`, `docs/context/conventions.md`, `docs/context/module-index.md`, `docs/llm-wiki/03-feature-index.md`, `AGENTS.md`. + +Date: 2026-10-29. diff --git a/docs/context/module-index.md b/docs/context/module-index.md index bdc4f92d..f9c1509c 100644 --- a/docs/context/module-index.md +++ b/docs/context/module-index.md @@ -42,7 +42,7 @@ Notes: Stored historical experiment payloads may depend on legacy algorithm keys ## `src/app/pages/experiment-studio` Purpose: Experiment composition workflow. -Key files: `experiment-studio.component.*`, `variables-panel/*`, `statistic-analysis-panel/*` (including `pca-column-source` / `kmeans-cluster-source`), `algorithm-panel/*`, `execution-panel/*` (result view + docked setup summary), `stepper/*`, `shared/*`, `guide/*`. +Key files: `experiment-studio.component.*`, `variables-panel/*`, `statistic-analysis-panel/*` (including `pca-column-source` / `kmeans-cluster-source`), `algorithm-panel/*` (thin shell; `algorithm-selection/` catalog, details, parameter form and outlier_report editor; `experiment-result/` run needs, readiness bar, run and exports; feature services `AlgorithmCatalogService`, `AlgorithmConfigFormService`, `AlgorithmRunService`, `AlgorithmStudioSubstepService` provided by the panel; `algorithm-result/` is shared and stays put), `execution-panel/*` (result view + docked setup summary), `stepper/*`, `shared/*`, `guide/*`. Used by: `/experiment-studio` route and dashboard edit flows. Rules: Keep page-specific UI here; use `ExperimentStudioService` for shared selection/run state. Transformation cards append to `appliedTransformations` in creation order; each creator step names outputs with `new_column_names`. Do not reintroduce `pca_with_transformation`. Tests: Component specs live near components; e2e covers PCA then K-means then logistic regression payload order in `e2e/experiment-studio.flows.spec.ts`. diff --git a/docs/llm-wiki/03-feature-index.md b/docs/llm-wiki/03-feature-index.md index b3fdbab0..95c2da81 100644 --- a/docs/llm-wiki/03-feature-index.md +++ b/docs/llm-wiki/03-feature-index.md @@ -19,7 +19,9 @@ Start with: - `ExperimentStudioComponent` (step shell) and `StudioStepperComponent` (step rail) - `ExperimentStudioService` (selection, algorithm roles, run state) - `execution-panel/` for run status/results and - `algorithm-panel/algorithm-role-assignment/` for the y/x slots + `algorithm-panel/algorithm-role-assignment/` for the y/x slots; the rest of + `algorithm-panel/` is a shell over `algorithm-selection/` (catalog, parameters) + and `experiment-result/` (run needs, readiness bar, run and exports) - `shared/station-card`, `shared/station-action-bar`, `shared/station-list-row` for the cards and footers every Data review step reuses - relevant model under `src/app/models/` diff --git a/src/app/core/algorithm-input-counts.spec.ts b/src/app/core/algorithm-input-counts.spec.ts index dcf037c8..ba7088d2 100644 --- a/src/app/core/algorithm-input-counts.spec.ts +++ b/src/app/core/algorithm-input-counts.spec.ts @@ -1,6 +1,5 @@ import { applyFallbackInputCounts, - formatInputCountRange, readInputCount, resolveInputMaxCount, resolveInputMinCount, @@ -29,8 +28,5 @@ describe('algorithm input counts', () => { expect(resolveInputMaxCount(y, 'y', 'glmm_binary')).toBe(1); expect(resolveInputMinCount(x, 'x', 'glmm_binary')).toBe(2); - expect(formatInputCountRange(1, 1)).toBe('exactly 1'); - expect(formatInputCountRange(2, null)).toBe('2+'); - expect(formatInputCountRange(2, 2)).toBe('exactly 2'); }); }); diff --git a/src/app/core/algorithm-input-counts.ts b/src/app/core/algorithm-input-counts.ts index eb983634..009bb75e 100644 --- a/src/app/core/algorithm-input-counts.ts +++ b/src/app/core/algorithm-input-counts.ts @@ -65,16 +65,6 @@ export function resolveInputMaxCount( } -export function formatInputCountRange(minCount: number, maxCount: number | null): string { - if (minCount === 1 && maxCount === 1) return 'exactly 1'; - if (minCount === 0 && maxCount === 1) return '0-1'; - if (minCount > 0 && maxCount !== null && minCount === maxCount) return `exactly ${minCount}`; - if (minCount > 0 && maxCount !== null) return `${minCount}-${maxCount}`; - if (minCount > 0) return `${minCount}+`; - if (maxCount !== null) return `0-${maxCount}`; - return 'optional'; -} - export function applyFallbackInputCounts(inputdata: unknown, algorithmName: string): Record { if (!inputdata || typeof inputdata !== 'object') return {}; const next: Record = { ...(inputdata as Record) }; diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css index 8332c345..73f2bf61 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.css @@ -3,9 +3,7 @@ master–detail row below it — Algorithm catalog on the left, Algorithm configuration (documentation + parameters) scrolling in the column on the right. Tokens per DESIGN.md — typography, borders, subtle tints; no icon fonts. */ - /* ROOT LAYOUT - CONTAINER */ - .experiment-container { display: grid; grid-template-columns: minmax(0, 1fr); @@ -38,1486 +36,66 @@ color: var(--text-main); } -.algorithm-roles-board__header { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 12px 16px; - padding: 12px 14px; - border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); - background: var(--studio-card-header-bg, #f8fbff); -} - -.algorithm-roles-board__title { - margin: 0 0 2px; - font-weight: 700; - line-height: 1.25; - letter-spacing: 0.01em; - color: var(--text-main); -} - -/* Live runnable/total counter — tabular so filtering never reflows the header. */ -.algorithm-count-badge { - gap: 6px; - flex: 0 0 auto; -} - -.algorithm-count-badge strong { - color: var(--primary-color); - font-size: 1rem; - font-weight: 800; - font-variant-numeric: tabular-nums; - letter-spacing: -0.01em; -} - -.algorithm-count-badge span { - font-variant-numeric: tabular-nums; -} - -/* BOTTOM ROW: Algorithm catalog (left) · Algorithm configuration (right). The row - grows with its content (24rem floor) instead of being pinned to a short fixed - height, so the parameter selection expands and every parameter field is readable - in the normal flow — the window is the scroller rather than a nested inner scroll. - The assignment rails sit above the row in flow, so switching methods never moves - them out from under the pointer; only space below the row changes. */ - -.algorithm-workspace { - display: grid; - grid-template-columns: minmax(300px, 20%) minmax(0, 80%); /* 20% catalog · 80% configuration */ - min-width: 0; - min-height: 24rem; - height: auto; - overflow: hidden; -} - -/* TOP BAND: Experiment pool across the full card width — the variable list on the left, - the stacked outcome and predictor rails beside it on the right — so the band only has - to be tall enough for the scrolling list. It is one shrinking grid track rather than a - stretched flex cross-size: that is what lets the band stop at its max-height and hand - the overflow to the list's own scroll, instead of clipping the hint out of the card. */ -.algorithm-pool { - display: grid; - grid-template-columns: minmax(0, 1fr); - grid-template-rows: minmax(0, 1fr); - flex: 0 0 auto; - min-width: 0; - min-height: 0; - border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); -} - -.algorithm-pool > app-algorithm-role-assignment { - min-width: 0; - min-height: 0; -} - -/* LEFT OF THE BOTTOM ROW: ALGORITHM CATALOG */ - -/* The catalog title mirrors the pool's pane head so the two panes read as peers. */ -.catalog-title { - margin: 0; - font-size: 0.68rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--primary-color); -} - -.algorithm-catalog { - display: flex; - flex-direction: column; - gap: 10px; - padding: 12px 14px 14px; - min-width: 0; - min-height: 0; - overflow: hidden; - color: var(--text-main); - box-sizing: border-box; - border-right: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); -} - -.algorithm-match-empty { - margin: 0; - padding: 14px 12px; - font-size: 0.78rem; - color: var(--text-muted); - line-height: 1.45; - border: 1px dashed rgba(148, 163, 184, 0.4); - border-radius: var(--radius-md, 8px); -} - -/* Search */ - -.catalog-search { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} - -/* The clear button overlays the field, so the field — not the row — is its containing - block; the filter chip sits beside the field instead of under it. */ -.catalog-search-field { - position: relative; - display: block; - flex: 1 1 auto; - /* Below this the chip drops to its own line rather than crushing the field: the - row wraps on this minimum plus the chip, so the field never shrinks under a - readable width when the catalog is narrow. */ - min-width: 8rem; -} - -.catalog-search-input { - display: block; - width: 100%; - min-width: 0; - box-sizing: border-box; - padding: 8px 34px 8px 12px; - border-radius: var(--radius-md, 8px); - border: 1px solid rgba(148, 163, 184, 0.34); - background: var(--studio-card-bg); - color: var(--text-main); - font: inherit; - font-size: 0.85rem; - line-height: 1.4; -} - -.catalog-search-input:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px rgba(127, 156, 232, 0.16); -} - -.catalog-search-input::placeholder { - color: #94a3b8; -} - -.catalog-search-clear { - position: absolute; - right: 6px; - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - padding: 0; - border: 0; - border-radius: var(--radius-sm, 4px); - background: transparent; - color: var(--text-muted); - font: inherit; - font-size: 1rem; - line-height: 1; - cursor: pointer; -} - -.catalog-search-clear:hover, -.catalog-search-clear:focus-visible { - background: color-mix(in srgb, var(--primary-color) 8%, white); - color: var(--text-main); -} - -/* One status chip (All / Runnable) sits on the search row — see .pill-toggle below. */ - -/* Method list. Category kicker: chevron first, then the group name (per the mock). */ - -.algorithm-match-rows { - display: flex; - flex-direction: column; - gap: 10px; - flex: 1 1 auto; - min-height: 0; - overflow: auto; - padding-right: 2px; -} - -.algorithm-match-row { +/* Panel-level empty state (no variables selected yet) */ +.algorithm-panel-empty { + margin: 14px 0 18px; + min-height: min(280px, calc(100vh - 320px)); display: flex; flex-direction: column; - gap: 6px; -} - -/* Category kicker doubles as the disclosure control for its tiles. */ -.algorithm-match-cat { - appearance: none; - display: flex; - align-items: center; - gap: 8px; - width: 100%; - margin: 0; - padding: 2px 0; - border: 0; - background: none; - font-family: inherit; - font-size: 0.8rem; - font-weight: 700; - letter-spacing: 0.01em; - color: var(--primary-color); - line-height: 1.3; - text-align: left; - cursor: pointer; -} - -.algorithm-match-cat-name { - min-width: 0; -} - -.algorithm-match-cat-meta { - display: inline-flex; align-items: center; justify-content: center; - flex: 0 0 auto; - order: -1; /* chevron sits before the label */ - color: var(--primary-color); -} - -/* Collapsed points right (›), expanded points down (⌄). */ -.algorithm-match-cat-meta::after { - content: ""; - width: 0.4rem; - height: 0.4rem; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: rotate(-45deg); - transition: transform 0.2s ease; -} - -.algorithm-match-cat[aria-expanded="true"] .algorithm-match-cat-meta::after { - transform: rotate(45deg); -} - -.algorithm-match-cat:hover .algorithm-match-cat-meta, -.algorithm-match-cat:focus-visible .algorithm-match-cat-meta { - color: var(--primary-color); -} - -.algorithm-match-tiles { - display: flex; - flex-direction: column; - gap: 6px; -} - -/* Algorithm row: plain label with trailing role chips; no selected highlight — - the chosen method is named in the details and parameters bands below. */ - -.algo-tile { - appearance: none; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 2px; - width: 100%; - max-width: 100%; - border-radius: var(--radius-md, 8px); - border: 1px solid transparent; - background: #ffffff; - color: var(--text-main); - font: inherit; - font-size: 0.8rem; - font-weight: 500; - line-height: 1.45; - text-align: left; - cursor: pointer; - transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; -} - -.algo-tile:hover:not(.disabled-algo), -.algo-tile:focus-visible:not(.disabled-algo) { - border-color: rgba(43, 51, 233, 0.22); - background: color-mix(in srgb, var(--primary-light) 10%, white); -} - -/* Selected method: accent bar, primary border, and a subtle tint so the tile the - configuration column on the right refers to is unmistakable. */ -.algo-tile.selected:not(.disabled-algo) { - border-color: var(--primary-color); - background: color-mix(in srgb, var(--primary-color) 8%, white); - box-shadow: inset 3px 0 0 var(--primary-color); -} - -/* The chevron points from the chosen tile into the configuration column, so the eye - lands on the details without scanning. Hidden until the tile is the selection. */ -.algo-tile-cue { - display: none; - flex: 0 0 auto; - width: 6px; - height: 6px; - margin-left: auto; - border-top: 2px solid var(--primary-color); - border-right: 2px solid var(--primary-color); - transform: rotate(45deg); -} - -.algo-tile.selected:not(.disabled-algo) .algo-tile-cue { - display: block; + gap: 12px; + padding: 36px 28px; + text-align: center; + border-radius: var(--radius-lg, 12px); + background: rgba(255, 255, 255, 0.4); + border: 1px solid rgba(255, 255, 255, 0.5); + box-shadow: inset 0 2px 10px rgba(43, 51, 233, 0.02); } -.algo-tile.selected:not(.disabled-algo) .algo-label { - color: var(--primary-color); +.algorithm-panel-empty__title { + margin: 4px 0 0; + font-size: 1rem; font-weight: 700; -} - -.algo-tile-top { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - min-width: 0; -} - -.algo-tile .algo-label { - min-width: 0; color: var(--text-main); - font-weight: 500; - overflow-wrap: anywhere; -} - -/* Required-input chips: amber y (outcome), lavender x (predictor) */ - -.algo-why { - display: block; - font-weight: 500; - line-height: 1.4; -} - -.algo-tile.disabled-algo { - cursor: pointer; - background: color-mix(in srgb, var(--bg-neutral, #f8fafc) 55%, white); - border-color: transparent; - box-shadow: none; - color: var(--text-muted); -} - -.algo-tile.disabled-algo .algo-label { - color: var(--text-muted); -} - -.algo-tile.disabled-algo:hover, -.algo-tile.disabled-algo:focus-visible { - background: color-mix(in srgb, var(--text-muted) 8%, white); + line-height: 1.3; } -/* A read-only preview selection of an unavailable method: same accent bar, muted. */ -.algo-tile.disabled-algo.selected { - border-color: rgba(100, 116, 139, 0.55); - background: color-mix(in srgb, var(--text-muted) 8%, white); - box-shadow: inset 3px 0 0 rgba(100, 116, 139, 0.55); +.algorithm-panel-empty__text { + margin: 0; + max-width: 420px; + font-size: 0.84rem; + line-height: 1.55; + color: #64748b; } -/* RIGHT OF THE BOTTOM ROW: ALGORITHM CONFIGURATION — documentation and parameter - fields share one column that grows with its content, so a parameter-rich method - reveals its whole form instead of clipping it under a fixed-height inner scroll. - The board paints the frame: this column adds no border, tint, or shadow of its - own. */ - -.algorithm-config-column { - display: flex; - flex-direction: column; - min-width: 0; - min-height: 0; +/* The role band owns its own height now: the expanded roster caps itself and + scrolls; the pool no longer forces the whole assignment band into 22rem. */ +.algorithm-roles-board { + /* visible keeps the run bar sticking to the page scroller; the bar rounds its + own bottom corners so the card frame stays intact. */ overflow: visible; } -/* PARAMETERS — continue the configuration flow directly under the documentation; - the unified card paints the frame. */ - -.algorithm-params { +/* Readiness bar: Run lives in the step bar, so this closes the panel with state only. */ +.algorithm-run-bar { + position: sticky; + bottom: 0; + z-index: 5; + border-radius: 0 0 var(--radius-lg, 12px) var(--radius-lg, 12px); display: flex; - flex-direction: column; - flex: 1 1 auto; - gap: 10px; - padding: 0 16px 16px; - min-width: 0; - box-sizing: border-box; - color: var(--text-main); -} - -.inspector-tag { - display: inline-flex; align-items: center; - padding: 1px 8px; - border-radius: 999px; - border: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.14)); - background: var(--bg-neutral); - color: var(--text-muted); - font-size: 0.64rem; - font-weight: 700; -} - -.algorithm-params__body { - display: flex; - flex-direction: column; - gap: 10px; - min-width: 0; -} - -.algorithm-params__empty { - display: flex; - flex-direction: column; - flex: 1 1 auto; - border-radius: var(--radius-md, 8px); -} - -.algorithm-params__empty-title { - margin: 0; - font-size: 0.92rem; - font-weight: 700; - color: var(--text-main); - line-height: 1.35; -} - -.algorithm-params__empty-text { - margin: 0; - max-width: 30rem; - font-size: 0.8rem; - line-height: 1.6; - color: var(--text-muted); -} - -/* TOP OF THE CONFIGURATION COLUMN — one continuous Algorithm configuration section: - header, description, and documentation. It no longer pins: the column expands to - its content and the window scrolls, so a sticky head would hang over the scrolling - page and hide the parameter fields below it instead of helping them. */ - -.algorithm-details { - display: flex; - flex-direction: column; - gap: 8px; - position: static; - background: var(--studio-card-bg); - color: var(--text-main); -} - -.algorithm-details__header { - flex-direction: column; -} - -.algorithm-details__heading { + justify-content: space-between; flex-wrap: wrap; -} - -/* Documentation */ - -.documentation-panel { - margin: 0; -} - -/* The native disclosure triangle is hidden, so the summary states its own affordance: - a chevron that turns with the state, driven by [open] on the
itself. */ -.documentation-summary { - gap: 8px; - cursor: pointer; - list-style: none; -} - -.documentation-summary::-webkit-details-marker { - display: none; -} - -/* Same chevron geometry as the catalog groups: right when collapsed, down when open. */ -.documentation-chevron { - flex: 0 0 auto; - width: 0.45rem; - height: 0.45rem; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: rotate(-45deg); - transition: transform 0.2s ease; -} - -.documentation-panel[open] > .documentation-summary .documentation-chevron { - transform: rotate(45deg); -} - -.documentation-content { - font-size: 0.82rem; - line-height: 1.45; - color: rgba(20, 22, 31, 0.72); - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -.config-description { - margin: 0; - font-size: 0.86rem; - line-height: 1.55; + gap: 8px 16px; + padding: 12px 18px; + border-top: 1px solid var(--studio-card-header-border); + background: var(--studio-card-header-bg); + font-size: 0.8rem; color: var(--text-muted); } -/* Run requirements */ - -.config-form { - display: flex; - flex-direction: column; - gap: 10px; -} - -.config-field { - flex-direction: column; -} - -/* Fields are divided by a hairline rather than framed: the parameters read as one - form, and the card already paints the only container. */ -.config-field + .config-field { - border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); - padding-top: 10px; -} - -/* Label + "Required" */ - -.config-field>label { - font-weight: 700; - font-size: 0.72rem; - color: var(--text-muted); - margin-bottom: 5px; - display: flex; - align-items: center; - gap: 8px; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.config-field-desc { - margin: 0 0 8px; - font-size: 0.8rem; - line-height: 1.45; - color: #64748b; -} - -.config-field-hint { - margin: 0 0 8px; - font-size: 0.78rem; - line-height: 1.4; - color: var(--text-muted); -} - -/* Inputs / selects */ - -.config-input, -.config-select { - display: block; - width: 100%; - min-width: 0; - box-sizing: border-box; - padding: 9px 12px; - background: rgba(255, 255, 255, 0.98); - border-radius: var(--radius-md, 8px); - border: 1px solid rgba(148, 163, 184, 0.34); - font-size: 0.85rem; - line-height: 1.4; - transition: border-color 0.18s ease, box-shadow 0.18s ease, - background-color 0.18s ease; - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.85) inset; -} - -.config-select { - padding-right: 30px; -} - -.config-select[multiple] { - min-height: 7rem; - padding-right: 12px; -} - -.config-input:focus, -.config-select:focus { - outline: none; - border-color: var(--primary-color); - background-color: var(--studio-card-bg); - box-shadow: - 0 0 0 3px rgba(127, 156, 232, 0.16), - 0 4px 12px rgba(43, 51, 233, 0.08); -} - -/* Checkbox wrapper */ - -.checkbox-wrapper { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 0.85rem; - color: var(--text-main); -} - -.checkbox-wrapper input[type="checkbox"] { - width: 18px; - height: 18px; - border-radius: 5px; -} - -/* Validation states */ - -.config-error { - color: #b42318; - font-size: 0.74rem; - margin-top: 5px; -} - -/* Validation states: valid stays neutral; only the invalid edge and message signal a problem. */ - -/* Placeholders */ - -.config-placeholder { - width: 100%; - display: flex; - align-items: center; - gap: 16px; - padding: 14px 16px; - border-radius: var(--radius-md, 8px); - border: 1px solid rgba(226, 232, 240, 0.95); - background: rgba(248, 250, 252, 0.65); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85); -} - -.config-placeholder--minimal { - width: auto; - max-width: 100%; - min-height: 0; -} - -.config-placeholder-content { - display: flex; - flex-direction: column; - gap: 6px; - max-width: 560px; -} - -.config-placeholder-content h5, -.config-placeholder-content h6 { - margin: 0; - color: var(--text-main); - font-size: 0.92rem; - font-weight: 700; -} - -.config-placeholder-content p { - margin: 0; - color: var(--text-muted); - font-size: 0.82rem; - line-height: 1.6; -} - -/* Outlier report table */ - -.outlier-report-editor { - display: flex; - flex-direction: column; - gap: 8px; - padding: 4px 0; -} - -.outlier-editor-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; -} - -.outlier-editor-header h6 { - margin: 0; - font-size: 0.9rem; - font-weight: 700; - color: var(--text-main); -} - -.outlier-count { - font-size: 0.72rem; - font-weight: 600; - color: var(--text-muted); - font-variant-numeric: tabular-nums; -} - -.outlier-table-wrapper { - overflow-x: auto; -} - -.outlier-table { - width: 100%; - border-collapse: collapse; - font-size: 0.8rem; -} - -.outlier-table th { - padding: 6px 8px; - text-align: left; - font-size: 0.68rem; - font-weight: 700; - letter-spacing: 0.05em; - text-transform: uppercase; - color: var(--text-muted); - border-bottom: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); -} - -.outlier-table td { - padding: 6px 8px; - vertical-align: top; - border-bottom: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); -} - -.outlier-table tr:last-child td { - border-bottom: 0; -} - -.outlier-variable-label { - display: block; - font-weight: 600; - color: var(--text-main); -} - -.outlier-table small { - color: var(--text-muted); - font-size: 0.68rem; -} - -.outlier-table .compact-select, -.outlier-table .compact-input { - min-width: 6.5rem; - padding: 5px 8px; - font-size: 0.78rem; -} - -.outlier-enable { - min-height: 24px; -} - -/* Panel-level empty state (no variables selected yet) */ - -.algorithm-panel-empty { - margin: 14px 0 18px; - min-height: min(280px, calc(100vh - 320px)); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - padding: 36px 28px; - text-align: center; - border-radius: var(--radius-lg, 12px); - background: rgba(255, 255, 255, 0.4); - border: 1px solid rgba(255, 255, 255, 0.5); - box-shadow: inset 0 2px 10px rgba(43, 51, 233, 0.02); -} - -.algorithm-panel-empty__title { - margin: 4px 0 0; - font-size: 1rem; - font-weight: 700; - color: var(--text-main); - line-height: 1.3; -} - -.algorithm-panel-empty__text { - margin: 0; - max-width: 420px; - font-size: 0.84rem; - line-height: 1.55; - color: #64748b; -} - -/* Tooltip (hover affordance for catalog cards) */ - -.tooltip { - position: absolute !important; - background: var(--studio-card-bg); - box-shadow: 0 12px 32px rgba(15, 23, 42, 0.22); - padding: 16px; - border-radius: var(--radius-lg, 12px); - width: 320px; - max-width: calc(100vw - 40px); - max-height: 70vh; - overflow-y: auto; - z-index: 99999; - pointer-events: none; - border: 1px solid rgba(226, 232, 240, 0.8); - opacity: 1; - transition: opacity 0.15s ease-out; -} - -.tooltip-title { - margin: 0 0 8px; - font-size: 1rem; - font-weight: 700; - color: var(--primary-color); - line-height: 1.3; -} - -.tooltip-description { - font-size: 0.82rem; - line-height: 1.5; - color: var(--text-muted); - margin-bottom: 0; -} - -.tooltip-requirements { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid rgba(226, 232, 240, 0.6); -} - -.tooltip-requirements-header { - font-size: 0.72rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - color: #94a3b8; - margin-bottom: 8px; -} - -.req-item { - display: flex; - align-items: flex-start; - gap: 10px; - margin-bottom: 8px; -} - -.req-copy { - display: flex; - flex-direction: column; - gap: 2px; -} - -.req-text { - font-size: 0.78rem; - color: var(--chart-label-color); - line-height: 1.4; -} - -.req-error { - font-size: 0.72rem; - color: #b45309; - line-height: 1.35; -} - -/* Shared fieldset host: the unified card paints the frame, not the fieldset. */ - -.algorithm-readonly-fieldset { - border: 0; - padding: 0; - margin: 0; - min-width: 0; -} - -.sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); -} - -/* RESPONSIVE LAYOUT */ - -@media (max-width: 960px) { - .algorithm-workspace { - grid-template-columns: minmax(0, 1fr); - height: auto; - min-height: 0; - } - - /* Stacked: head, rails, list and hint sit in one column, so the band hugs that - taller content and the window scrolls rather than the band scrolling inside it. */ - .algorithm-pool { - border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); - } - - .algorithm-catalog { - border-right: 0; - border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); - max-height: 24rem; - overflow: auto; - } - -} - -@media (max-width: 640px) { - .algo-why { - max-width: 100%; - } -} - -@media (prefers-reduced-motion: reduce) { - .algo-tile, - .algorithm-match-cat-meta::after, - .documentation-chevron { - transition: none; - } -} - -/* ========================================================================= - Algorithm panel review pass: step kickers, compact role band, catalog, - needs checklist, parameter layout, advanced grouping, option switches, - and the readiness bar. - ========================================================================= */ - -.algorithm-step-kicker { - padding: 10px 14px 0; -} - -.step-kicker { - display: flex; - align-items: center; - gap: 8px; - font-size: 0.72rem; - font-weight: 700; - letter-spacing: var(--letter-spacing-config); - text-transform: uppercase; - color: var(--text-muted); -} - -.step-kicker-n { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 50%; - box-shadow: inset 0 0 0 1.5px var(--studio-card-border); - font-size: 0.68rem; -} - -.algorithm-roles-board__title { - font-size: 0.96rem; -} - -.algorithm-count-badge { - display: inline-flex; - align-items: center; - padding: 2px 10px; - border: 0; - border-radius: 999px; - background: color-mix(in srgb, var(--primary-color) 8%, white); - color: var(--primary-color); - font-size: 0.72rem; - font-weight: 600; - white-space: nowrap; -} - -/* The role band owns its own height now: the expanded roster caps itself and - scrolls; the pool no longer forces the whole assignment band into 22rem. */ -.algorithm-roles-board { - /* visible keeps the run bar sticking to the page scroller; the bar rounds its - own bottom corners so the card frame stays intact. */ - overflow: visible; -} - -.algorithm-pool { - height: auto; - max-height: none; - overflow: visible; -} - -.algorithm-pool > app-algorithm-role-assignment { - overflow: visible; -} - -.catalog-mode-tabs { - flex: 0 0 auto; -} - -.catalog-mode-tabs button { - gap: 4px; - min-height: 26px; - padding: 0 10px; - font-size: 0.74rem; -} - -.catalog-title.sr-only { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); -} - -.algorithm-match-cat-count { - margin-left: auto; - padding-left: 10px; - color: var(--text-muted); - font-size: 0.72rem; - font-weight: 600; -} - -.algo-tile { - min-height: 36px; - padding: 8px 10px; -} - -.algo-tile-top::before { - content: ''; - flex: 0 0 auto; - width: 10px; - height: 10px; - border-radius: 50%; - box-shadow: inset 0 0 0 1.5px rgba(71, 85, 105, 0.45); -} - -.algo-tile.selected .algo-tile-top::before { - box-shadow: inset 0 0 0 3.5px var(--primary-color); -} - -.algo-tile.disabled-algo .algo-tile-top::before { - box-shadow: inset 0 0 0 1.5px rgba(71, 85, 105, 0.25); -} - -.algo-why { - font-size: 0.74rem; - color: #78350f; -} - -/* Configuration head: number ring, real method label, category tag. */ -.algorithm-details { - padding: 14px 16px 8px; -} - -.algorithm-details__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.algorithm-details__heading { - display: flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.algorithm-details__title { - margin: 0; - min-width: 0; - font-size: 1.1rem; - font-weight: 700; - line-height: 1.3; - color: var(--text-main); - overflow-wrap: anywhere; -} - -.inspector-tag { - flex: 0 0 auto; - text-transform: none; - letter-spacing: 0; -} - -.config-description { - max-width: 60ch; - margin-top: 6px; -} - -/* Documentation is a quiet text link by default. */ -.documentation-panel { - margin-top: 2px; -} - -.documentation-summary { - display: inline-flex; - align-items: center; - padding: 2px 0; - margin: 0; - border-radius: 0; - background: none; - font-size: 0.8rem; - font-weight: 600; - color: var(--primary-color); -} - -.documentation-summary:hover { - background: none; - text-decoration: underline; -} - -.documentation-chevron { - display: none; -} - -.documentation-panel[open] > .documentation-summary { - border-bottom: 0; - border-radius: 0; -} - -.documentation-content { - margin-top: 6px; - padding-left: 0; - border-left: 0; -} - -/* Needs checklist */ -.algo-needs { - display: grid; - gap: 4px; - margin: 0 0 14px; - padding: 10px 12px; - list-style: none; - border-radius: var(--radius-md); - background: var(--bg-neutral); - font-size: 0.8rem; -} - -.algo-needs li { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px 8px; -} - -.algo-needs-mark { - width: 12px; - height: 12px; - border-radius: 50%; - box-shadow: inset 0 0 0 1.5px #a06b10; - transform: translateY(2px); -} - -.algo-needs li.is-met .algo-needs-mark { - background: var(--success); - box-shadow: none; -} - -.algo-needs small { - flex-basis: 100%; - padding-left: 20px; - color: #78350f; -} - -.algo-needs-all-met { - margin: 0 0 14px; - padding: 8px 12px; - border-radius: var(--radius-md); - background: color-mix(in srgb, var(--success) 10%, white); - color: #065f46; - font-size: 0.8rem; - font-weight: 600; -} - -/* Parameters */ -.algorithm-params__title { - margin: 0 0 2px; - font-size: 0.72rem; - font-weight: 700; - letter-spacing: var(--letter-spacing-config); - text-transform: uppercase; - color: var(--text-muted); -} - -/* Parameter rows: label beside control, sentence case, optional tag. */ -.config-field { - display: grid; - grid-template-columns: 220px minmax(0, 360px); - gap: 2px 16px; - align-items: start; - padding: 10px 0; -} - -.config-field > label { - grid-column: 1; - display: flex; - align-items: center; - gap: 8px; - margin: 0; - padding-top: 8px; - font-size: 0.84rem; - font-weight: 600; - color: var(--text-main); - text-transform: none; - letter-spacing: 0; -} - -.config-field-desc, -.config-field-hint { - grid-column: 1; - margin: 0; - font-size: 0.76rem; -} - -.config-field > .config-input, -.config-field > .config-select, -.config-field > .checkbox-wrapper, -.config-field > .chip-picker, -.config-field > .validation-message { - grid-column: 2; - grid-row: 1 / span 3; -} - -.optional-tag { - margin-left: 4px; - font-size: 0.66rem; - font-weight: 600; - color: var(--text-muted); -} - -.chip-picker { - display: flex; - flex-wrap: wrap; - gap: 6px; - min-width: 0; -} - -.chip-picker-option { - min-height: 28px; - padding: 0 10px; - border: 1px solid rgba(148, 163, 184, 0.34); - border-radius: 999px; - background: #fff; - color: var(--text-main); - font: inherit; - font-size: 0.76rem; - cursor: pointer; -} - -.chip-picker-option.active { - border-color: var(--primary-color); - background: color-mix(in srgb, var(--primary-color) 8%, white); - color: var(--primary-color); - font-weight: 600; -} - -/* Advanced fields stay behind a disclosure, closed by default. */ -.config-advanced { - border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); - margin-top: 4px; -} - -.config-advanced__summary { - display: flex; - align-items: center; - gap: 8px; - padding: 10px 0; - list-style: none; - cursor: pointer; - font-size: 0.84rem; - font-weight: 600; - color: var(--text-main); -} - -.config-advanced__summary::-webkit-details-marker { - display: none; -} - -.config-advanced__chevron { - width: 0.4rem; - height: 0.4rem; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: rotate(-45deg); - transition: transform 0.2s ease; -} - -.config-advanced[open] > .config-advanced__summary .config-advanced__chevron { - transform: rotate(45deg); -} - -.config-advanced__body { - padding-bottom: 6px; -} - -/* Option rows: cross-validation uses the studio switch. */ -.config-option-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - padding: 12px 0; - border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); -} - -/* Settings that belong to the switch above sit under it, inset to read as its children. */ -.config-option-detail { - margin: -4px 0 8px; - padding-left: 14px; - border-left: 2px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); -} - -.config-option-detail .config-field { - grid-template-columns: 206px minmax(0, 360px); - padding: 4px 0; -} - -.config-option-row.is-locked { - opacity: 0.86; -} - -.config-option-copy { - display: flex; - flex-direction: column; - gap: 2px; - min-width: 0; -} - -.config-option-label { - font-size: 0.84rem; - font-weight: 600; - color: var(--text-main); -} - -.config-option-hint { - font-size: 0.76rem; - line-height: 1.4; - color: var(--text-muted); -} - -.toggle-switch { - position: relative; - display: inline-block; - flex: 0 0 auto; - width: 44px; - height: 24px; -} - -.toggle-switch.is-locked { - cursor: not-allowed; -} - -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-switch input:focus-visible + .toggle-slider { - outline: 2px solid var(--primary-color); - outline-offset: 2px; -} - -.toggle-slider { - position: absolute; - cursor: pointer; - inset: 0; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--text-muted) 75%, transparent); - background: color-mix(in srgb, var(--text-muted) 22%, white); - transition: background 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease; -} - -.toggle-slider::before { - content: ''; - position: absolute; - height: 18px; - width: 18px; - left: 3px; - top: 2.5px; - border-radius: 50%; - background: var(--card-bg); - box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18), inset 0 0 0 1px rgba(15, 23, 42, 0.12); - transition: transform 0.22s var(--ease-standard, ease), box-shadow 0.22s ease; -} - -.toggle-switch input:checked + .toggle-slider { - background: var(--primary-color); - border-color: rgba(43, 51, 233, 0.24); - box-shadow: 0 5px 14px color-mix(in srgb, var(--primary-color) 22%, transparent); -} - -.toggle-switch input:checked + .toggle-slider::before { - transform: translateX(20px); -} - -.toggle-switch input:disabled + .toggle-slider { - cursor: not-allowed; -} - -/* Muted validation: invalid keeps a danger edge; valid is neutral. */ -.config-input.ng-valid.ng-touched, -.config-select.ng-valid.ng-touched { - border: 1px solid rgba(148, 163, 184, 0.34); - background: #fff; - background-image: none; - box-shadow: none; - padding-right: 12px; -} - -.config-input.ng-invalid.ng-touched, -.config-select.ng-invalid.ng-touched { - border: 1px solid var(--danger); - background-color: #fff; - background-image: none; - box-shadow: none; - padding-right: 12px; -} - -.config-input.ng-invalid.ng-touched:focus, -.config-select.ng-invalid.ng-touched:focus { - border-color: var(--danger); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 16%, transparent); -} - -/* Readiness bar: Run lives in the step bar, so this closes the panel with state only. */ -.algorithm-run-bar { - position: sticky; - bottom: 0; - z-index: 5; - border-radius: 0 0 var(--radius-lg, 12px) var(--radius-lg, 12px); - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 8px 16px; - padding: 12px 18px; - border-top: 1px solid var(--studio-card-header-border); - background: var(--studio-card-header-bg); - font-size: 0.8rem; - color: var(--text-muted); -} - -.algorithm-run-status { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.algorithm-run-dot { - flex: 0 0 auto; - width: 8px; - height: 8px; - border-radius: 50%; - background: #b45309; -} - -.algorithm-run-bar.is-ready .algorithm-run-dot { - background: var(--success); -} - -.algorithm-run-text { - min-width: 0; -} - .algorithm-run-bar.is-ready { color: #065f46; font-weight: 600; } - -.algorithm-run-error { - flex-basis: 100%; - color: #8a1f1f; -} - -/* Empty state under the ③ kicker, without the dashed frame. */ -.algorithm-params__empty { - align-items: flex-start; - justify-content: flex-start; - gap: 6px; - margin: 0; - padding: 16px; - border: 0; - background: transparent; -} - -@media (max-width: 900px) { - .algorithm-details__header { - flex-wrap: wrap; - } - - .config-field, - .config-option-detail .config-field { - grid-template-columns: 1fr; - } - - .config-field > * { - grid-column: 1 !important; - grid-row: auto !important; - } - - .algorithm-workspace { - grid-template-columns: 1fr; - } -} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html index 9ef1e43a..134a95e9 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.html @@ -1,456 +1,9 @@
-@if (assignablePool.length > 0) { -
-
-
-
Algorithm
-
- {{ algoCounts().active }} of {{ algoCounts().all }} runnable -
- -
- -
- -
- - -
- @if (selectedAlgorithm(); as algorithm) { -
-
-
-
3
-

{{ algorithm.label }}

-
- @if (selectedAlgorithmCategory()) { - {{ selectedAlgorithmCategory() }} - } -
- @if (algorithm.description) { -

{{ algorithm.description }}

- } - @if (selectedAlgorithmDocumentation()) { -
- - - How this method works - -
{{ selectedAlgorithmDocumentation() }}
-
- } -
- } - -
- @if (selectedAlgorithm(); as algorithm) { -
- @if (algorithmNeeds().length) { -
    -
  • Complete before running
  • - @for (need of algorithmNeeds(); track need.key) { -
  • - - {{ need.title }} - @for (message of need.messages; track message) { - {{ message }} - } - @if (need.action) { - - } -
  • - } -
- } @else if (selectedAlgorithm()) { -

✓ All inputs match

- } - -
- - @if (hasAlgorithmParameterControls()) { -

Parameters

- } - - @if (isOutlierReportSelected()) { -
-
-
-
Per-variable Outlier Rules
-
- {{ outlierReportVariables().length }} numerical -
- - @if (outlierReportVariables().length > 0) { -
- - - - - - - - - - - - @for (variable of outlierReportVariables(); track variable.code) { - - - - - - - - } - -
VariableEnabledStrategyTailFold
- {{ outlierVariableLabel(variable) }} - {{ variable.code }} - - - - - - - - - @if (outlierReportRuleError(variable)) { -
{{ outlierReportRuleError(variable) }}
- } -
-
- } @else { -
-
-
No numerical variables selected
-
-
- } -
- } - - - @if (primaryConfigSchema().length || advancedConfigSchema().length) { -
- @for (field of primaryConfigSchema(); track field.key) { - - } - - @if (advancedConfigSchema().length) { -
- - - Advanced ({{ advancedConfigSchema().length }}) - -
- @for (field of advancedConfigSchema(); track field.key) { - - } -
-
- } -
- } - - @if (canToggleCrossValidation() || isCrossValidationOnly()) { -
-
- Cross-validation - - {{ isCrossValidationOnly() ? 'Always on for this method' : 'Runs the cross-validation variant and reports fold metrics' }} - -
- -
- - @if (crossValidationConfigSchema().length) { -
- @for (field of crossValidationConfigSchema(); track field.key) { - - } -
- } - } - - @if (!hasAlgorithmParameterControls()) { -
-
-
No additional parameters for this algorithm
-

- This algorithm does not expose extra configuration fields. Review the - documentation and run the experiment when you are ready. -

-
-
- } - -
- - - @if (configForm().get(field.key)) { -
- - - @if (field.desc) { -

{{ field.desc }}

- } - - @if (field.enumType === 'input_var_names' && field.type === 'select' && (!field.options || !field.options.length)) { -

- @if (field.enumSource?.includes('x')) { - Select covariates first. This parameter must be one of the variables in x. - } @else if (field.enumSource?.includes('y')) { - Select a variable first. This parameter must be one of the variables in y. - } @else { - Select the required variables before choosing this parameter. - } -

- } - - @if (field.key === 'positive_class' && !selectedEventVarCode()) { -

Select an event indicator variable first.

- } - - @if (field.key === 'positive_class' && selectedEventVarCode() && positiveClassOptions().length) { -

- Select the event level mapped to 1; all other levels are mapped to 0. -

- } - - @if (field.key === 'positive_class' && selectedEventVarCode() && !positiveClassOptions().length) { -

- This event variable has no catalog categories. Enter the value treated as event=1 (for example 0 or 1). -

- } - - @if (field.type === 'number') { - - - } @else if (field.type === 'select') { - - } @else if (field.type === 'multi-select') { -
- @for (opt of field.options; track opt) { - - } -
- } @else if (field.type === 'checkbox') { - - } @else { - - } - - @if (configForm().get(field.key)?.invalid && - configForm().get(field.key)?.touched) { -
- @if (configForm().get(field.key)?.hasError('required')) { - This field is required. - } @if (configForm().get(field.key)?.hasError('min')) { - Value too small. - } @if (configForm().get(field.key)?.hasError('max')) { - Value too large. - } @if (configForm().get(field.key)?.hasError('pattern')) { - Invalid value. - } @if (configForm().get(field.key)?.hasError('invalidOption')) { - Select a value from the list. - } -
- } -
- } -
-
- } @else { -
-
3
-

No algorithm selected

-

- Select a runnable algorithm from the catalog to configure parameters and run the - experiment. -

-
- } -
-
-
- -
- - - {{ readinessText() }} - - @if (!canRun()) { - Finish the needs above to enable the run. - } - @if (errorMsg()) { - Backend error: {{ errorMsg() }} - } -
+@if (assignablePool().length > 0) { +
+
- -@if (tooltipVisible()) { -
-
{{ tooltipData()?.label }}
-
- - @if (availabilityDetails().length) { -
-
Availability
- @for (detail of availabilityDetails(); track detail.role) { -
-
- {{ availabilityRequirementText(detail) }} - @for (message of detail.messages; track message) { - {{ message }} - } -
-
- } -
- } -
-} } @else {
Select variables first
diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts index b5adeaf6..49bea61a 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.spec.ts @@ -1,6 +1,7 @@ import type { MockedObject, Mock } from 'vitest'; import { provideZonelessChangeDetection, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { provideRouter } from '@angular/router'; import { AuthService } from '../../../services/auth.service'; import { ErrorService } from '../../../services/error.service'; @@ -8,11 +9,13 @@ import { PdfExportService } from '../../../services/pdf-export.service'; import { ExperimentStudioService } from '../../../services/experiment-studio.service'; import { RuntimeEnvService } from '../../../services/runtime-env.service'; import { ExperimentStudioNavigationService } from '../../../services/experiment-studio-navigation.service'; -import { SessionStorageService } from '../../../services/session-storage.service'; import { AlgorithmConfig } from '../../../models/algorithm-definition.model'; import { AlgorithmPanelComponent } from './algorithm-panel.component'; -import { of, throwError } from 'rxjs'; import { createSpyObj } from '../../../testing/spy-obj.testing'; +import { AlgorithmCatalogService } from './algorithm-selection/algorithm-catalog.service'; +import { AlgorithmCatalogComponent } from './algorithm-selection/algorithm-catalog/algorithm-catalog.component'; +import { AlgorithmConfigFormService } from './algorithm-selection/algorithm-config-form.service'; +import { normalizeParameterValues } from './utils/algorithm-parameter-form.utils'; describe('AlgorithmPanelComponent', () => { let fixture: ComponentFixture; @@ -146,7 +149,6 @@ describe('AlgorithmPanelComponent', () => { { provide: AuthService, useValue: { currentUser: null } }, { provide: PdfExportService, useValue: { exportExperimentPdf: vi.fn() } }, { provide: RuntimeEnvService, useValue: { mipVersion: 'test' } }, - { provide: SessionStorageService, useValue: {} }, { provide: ExperimentStudioNavigationService, useValue: studioNavigation }, ], }).compileComponents(); @@ -155,6 +157,13 @@ describe('AlgorithmPanelComponent', () => { fixture.detectChanges(); }); + /** The panel's feature services, as its template sees them. */ + const catalog = () => fixture.debugElement.injector.get(AlgorithmCatalogService); + const configForm = () => fixture.debugElement.injector.get(AlgorithmConfigFormService); + /** Picks a method as a click on its catalog tile does, even when its group is collapsed. */ + const chooseAlgorithm = (next: AlgorithmConfig) => + fixture.debugElement.query(By.directive(AlgorithmCatalogComponent)).componentInstance.onAlgorithmClick(next); + it('groups default optional parameter fields under a closed Advanced disclosure', async () => { fixture.componentInstance.setStudioSubstep('parameters'); await fixture.whenStable(); @@ -171,61 +180,14 @@ describe('AlgorithmPanelComponent', () => { expect(fixture.componentInstance.canRun()).toBe(true); }); - describe('variables missing from a selected dataset', () => { - const unavailable = new Map([ - ['age', ['Dataset B']], - ['sex', ['Dataset B', 'Dataset C']], - ]); - - it('blocks the run while an outcome, predictor or filter is unavailable', () => { - const component = fixture.componentInstance; - experimentStudioService.unavailableVariables.set(unavailable); - experimentStudioService.algorithmY.set([{ code: 'age', label: 'Age' }]); - experimentStudioService.algorithmX.set([{ code: 'mmse', label: 'MMSE' }]); - experimentStudioService.selectedFilters.set([{ code: 'sex', label: 'Sex' }]); - fixture.detectChanges(); - - expect(component.canRun()).toBe(false); - expect(component.runDisabledReason()).toBe('Remove variables not available in: Dataset B, Dataset C'); - expect(component.algorithmNeeds()).toContainEqual(expect.objectContaining({ - key: 'unavailable-variables', - met: false, - title: 'Remove variables not available in: Dataset B, Dataset C', - })); - expect((fixture.nativeElement as HTMLElement).textContent) - .toContain('Remove variables not available in: Dataset B, Dataset C'); - - component.onClickRunExp(); - expect(experimentStudioService.runSelectedAlgorithm).not.toHaveBeenCalled(); - expect(experimentStudioService.setRunning).not.toHaveBeenCalled(); - }); - - it('checks predictors on their own', () => { - experimentStudioService.unavailableVariables.set(unavailable); - experimentStudioService.algorithmX.set([{ code: 'age', label: 'Age' }]); - - expect(fixture.componentInstance.canRun()).toBe(false); - expect(fixture.componentInstance.runDisabledReason()).toBe('Remove variables not available in: Dataset B'); - }); - - it('runs again once the unavailable variables are removed', () => { - const component = fixture.componentInstance; - experimentStudioService.unavailableVariables.set(unavailable); - experimentStudioService.algorithmY.set([{ code: 'age', label: 'Age' }]); - expect(component.canRun()).toBe(false); - - experimentStudioService.algorithmY.set([{ code: 'mmse', label: 'MMSE' }]); - expect(component.canRun()).toBe(true); - expect(component.runDisabledReason()).toBe('Execute experiment'); - expect(component.algorithmNeeds().some((need) => need.key === 'unavailable-variables')).toBe(false); - }); - - it('ignores unavailable variables the run does not use', () => { - experimentStudioService.unavailableVariables.set(unavailable); - experimentStudioService.selectedVariables.set([{ code: 'age', label: 'Age' }]); + it('lists variables missing from a selected dataset as a run need', () => { + experimentStudioService.unavailableVariables.set(new Map([['age', ['Dataset B']]])); + experimentStudioService.algorithmY.set([{ code: 'age', label: 'Age' }]); + fixture.detectChanges(); - expect(fixture.componentInstance.canRun()).toBe(true); - }); + expect(fixture.componentInstance.canRun()).toBe(false); + expect((fixture.nativeElement as HTMLElement).textContent) + .toContain('Remove variables not available in: Dataset B'); }); it('keeps required parameters in the primary list and knows the friendly needs copy', async () => { @@ -294,7 +256,7 @@ describe('AlgorithmPanelComponent', () => { expect(details?.querySelector('.documentation-content')?.textContent).toContain('Line one.'); }); - it('shows disabled algorithm availability reasons on the card and in the tooltip', async () => { + it('shows disabled algorithm availability reasons on the card', async () => { const disabledAlgorithm: AlgorithmConfig = { ...algorithm, name: 'needs_two_variables', @@ -323,7 +285,7 @@ describe('AlgorithmPanelComponent', () => { }, }; experimentStudioService.availableGroupedAlgorithms.set({ Test: [disabledAlgorithm] }); - fixture.componentInstance.showOnlyActive.set(false); + catalog().showOnlyActive.set(false); fixture.detectChanges(); await fixture.whenStable(); @@ -331,40 +293,10 @@ describe('AlgorithmPanelComponent', () => { ((fixture.nativeElement as HTMLElement).querySelector('.algorithm-match-cat') as HTMLButtonElement).click(); fixture.detectChanges(); - const component = fixture.componentInstance; - component.tooltipVisible.set(true); - component.tooltipData.set(disabledAlgorithm); - fixture.detectChanges(); - const nativeElement = fixture.nativeElement as HTMLElement; const algorithmItem = nativeElement.querySelector('.algo-tile') as HTMLElement; expect(algorithmItem.textContent).toContain('Needs Two Variables'); expect(algorithmItem.querySelector('.algo-why')?.textContent).toContain('Outcome needs at least 2, selected 1.'); - expect(nativeElement.querySelector('.tooltip')?.textContent).toContain('Outcome needs at least 2, selected 1.'); - expect(nativeElement.textContent).toContain('Availability'); - expect(nativeElement.textContent).toContain('Outcome: 2-3, selected 1'); - expect(nativeElement.textContent).toContain('type: real'); - expect(nativeElement.querySelector('.tooltip')?.textContent).toContain('Outcome type must be one of real.'); - expect(nativeElement.textContent).not.toContain('type: nominal'); - expect(nativeElement.textContent).not.toContain('stattypes: nominal'); - }); - - it('displays text availability types as nominal in requirement tips', () => { - const text = fixture.componentInstance.availabilityRequirementText({ - role: 'x', - label: 'Predictor', - selectedCount: 1, - minCount: 1, - maxCount: null, - required: true, - types: ['real', 'int', 'text'], - stattypes: [], - messages: [], - satisfied: true, - }); - - expect(text).toContain('type: real, int, nominal'); - expect(text).not.toContain('text'); }); it('shows type availability reasons on algorithm cards', async () => { @@ -393,7 +325,7 @@ describe('AlgorithmPanelComponent', () => { }, }; experimentStudioService.availableGroupedAlgorithms.set({ Test: [disabledAlgorithm] }); - fixture.componentInstance.showOnlyActive.set(false); + catalog().showOnlyActive.set(false); fixture.detectChanges(); await fixture.whenStable(); @@ -429,7 +361,7 @@ describe('AlgorithmPanelComponent', () => { ? unavailableAlgorithm.availability : { available: true, summary: '', details: [] }); - fixture.componentInstance.showOnlyActive.set(false); + catalog().showOnlyActive.set(false); fixture.detectChanges(); await fixture.whenStable(); @@ -440,7 +372,7 @@ describe('AlgorithmPanelComponent', () => { const unavailableListItem = (fixture.nativeElement as HTMLElement).querySelector('.algo-tile.disabled-algo') as HTMLElement; expect(getComputedStyle(unavailableListItem).cursor).toBe('pointer'); - fixture.componentInstance.onAlgorithmClick(unavailableAlgorithm); + chooseAlgorithm(unavailableAlgorithm); fixture.detectChanges(); await fixture.whenStable(); @@ -471,15 +403,6 @@ describe('AlgorithmPanelComponent', () => { expect(experimentStudioService.runSelectedAlgorithm).not.toHaveBeenCalled(); }); - it('keeps fields after the old advanced cutoff in the persisted form config', async () => { - await fixture.whenStable(); - - const component = fixture.componentInstance; - component.configForm().get('fourth')?.setValue('42'); - - expect(experimentStudioService.algorithmConfigurations()['flat_config_algorithm']['fourth']).toBe(42); - }); - it('shows event_var description and covariate options for Cox regression', async () => { const coxAlgorithm: AlgorithmConfig = { name: 'cox_regression_classical', @@ -583,13 +506,12 @@ describe('AlgorithmPanelComponent', () => { expect(positiveSelect.textContent).toContain('-- Select --'); expect(root.textContent).toContain('Select the event level mapped to 1'); - const component = fixture.componentInstance; - expect(component.visibleConfigSchema().find((field) => field.key === 'positive_class')?.required).toBe(true); - const values = (component as any).normalizeFormValues( + expect(configForm().visibleConfigSchema().find((field) => field.key === 'positive_class')?.required).toBe(true); + const values = normalizeParameterValues( { event_var: 'procedure', positive_class: 'yes' }, - component.visibleConfigSchema() + configForm().visibleConfigSchema() ); - expect(values.positive_class).toBe('yes'); + expect(values['positive_class']).toBe('yes'); }); it('blocks Cox runs when positive_class is unset', async () => { @@ -643,7 +565,7 @@ describe('AlgorithmPanelComponent', () => { component.onClickRunExp(); expect(experimentStudioService.runSelectedAlgorithm).not.toHaveBeenCalled(); - expect(component.configForm().get('positive_class')?.invalid).toBe(true); + expect(configForm().configForm().get('positive_class')?.invalid).toBe(true); }); it('shows covariate hint for empty input_var_names selects', async () => { @@ -682,7 +604,7 @@ describe('AlgorithmPanelComponent', () => { expect(select?.options.length).toBe(1); }); - it('serializes outlier_report rules into dictionary parameters', async () => { + it('lists the numerical pool variables in the outlier_report editor', async () => { const outlierAlgorithm: AlgorithmConfig = { name: 'outlier_report', label: 'Outlier Report', @@ -698,92 +620,17 @@ describe('AlgorithmPanelComponent', () => { isDisabled: false, }; experimentStudioService.selectedAlgorithm.set(outlierAlgorithm); - experimentStudioService.backendAlgorithms.set({ outlier_report: outlierAlgorithm }); - experimentStudioService.availableGroupedAlgorithms.set({ 'Descriptive Statistics': [outlierAlgorithm] }); - experimentStudioService.selectedVariables.set([ - { code: 'age', label: 'Age', type: 'real' }, - { code: 'sex', label: 'Sex', type: 'nominal' }, - ]); - experimentStudioService.algorithmX.set([{ code: 'bmi', label: 'BMI', type: 'real' }]); experimentStudioService.algorithmAssignableVariables.set([ { code: 'age', label: 'Age', type: 'real' }, { code: 'sex', label: 'Sex', type: 'nominal' }, { code: 'bmi', label: 'BMI', type: 'real' }, ]); - experimentStudioService.hasAppliedDescriptivePreprocessing.mockReturnValue(false); - experimentStudioService.runId.set('stale-run'); - experimentStudioService.runSelectedAlgorithm.mockReturnValue(of({ - uuid: 'kept-run', - status: 'success', - result: { featurewise: [] }, - })); - - fixture.detectChanges(); - await fixture.whenStable(); - const component = fixture.componentInstance; - component.onOutlierReportEnabledChange({ code: 'bmi', label: 'BMI', type: 'real' }, false); - component.onOutlierReportStrategyChange({ code: 'age', label: 'Age', type: 'real' }, 'quantile'); - component.onOutlierReportFoldChange({ code: 'age', label: 'Age', type: 'real' }, 0.05); - component.onClickRunExp(); - - expect(experimentStudioService.algorithmConfigurations()['outlier_report']).toEqual({ - strategies: { age: 'quantile' }, - tails: { age: 'both' }, - folds: { age: 0.05 }, - }); - expect(experimentStudioService.captureRunSetup).toHaveBeenCalledWith('outlier_report'); - expect(experimentStudioService.runSelectedAlgorithm).toHaveBeenCalledWith('outlier_report', 'outlier_report'); - // The kept run is what Save persists, so it must be the run behind the shown result. - expect(experimentStudioService.runId()).toBe('kept-run'); - expect((fixture.nativeElement as HTMLElement).textContent).toContain('Age'); - expect((fixture.nativeElement as HTMLElement).textContent).toContain('BMI'); - }); - - it('shows the run error and stops running when the run request fails', async () => { - experimentStudioService.selectedAlgorithm.set(algorithm); - experimentStudioService.runSelectedAlgorithm.mockReturnValue(throwError(() => new Error('500'))); fixture.detectChanges(); await fixture.whenStable(); - fixture.componentInstance.onClickRunExp(); - - expect(experimentStudioService.runError()).toBe('Unable to run experiment. Please try again.'); - expect(experimentStudioService.runId()).toBeNull(); - expect(experimentStudioService.setRunning).toHaveBeenCalledWith(true); - expect(experimentStudioService.setRunning).toHaveBeenLastCalledWith(false); - }); - - it('blocks invalid outlier_report folds', async () => { - const outlierAlgorithm: AlgorithmConfig = { - name: 'outlier_report', - label: 'Outlier Report', - description: '', - category: 'Descriptive Statistics', - requiredVariable: ['real'], - covariate: ['real'], - configSchema: [ - { key: 'strategies', label: 'Strategies', type: 'dict' }, - { key: 'tails', label: 'Tails', type: 'dict' }, - { key: 'folds', label: 'Folds', type: 'dict' }, - ], - isDisabled: false, - }; - experimentStudioService.selectedAlgorithm.set(outlierAlgorithm); - experimentStudioService.selectedVariables.set([{ code: 'age', label: 'Age', type: 'real' }]); - experimentStudioService.algorithmAssignableVariables.set([{ code: 'age', label: 'Age', type: 'real' }]); - experimentStudioService.hasAppliedDescriptivePreprocessing.mockReturnValue(false); - fixture.detectChanges(); - await fixture.whenStable(); - - const component = fixture.componentInstance; - component.onOutlierReportStrategyChange({ code: 'age', label: 'Age', type: 'real' }, 'quantile'); - component.onOutlierReportFoldChange({ code: 'age', label: 'Age', type: 'real' }, 0.5); - experimentStudioService.runSelectedAlgorithm.mockClear(); - - component.onClickRunExp(); - - expect(experimentStudioService.runSelectedAlgorithm).not.toHaveBeenCalled(); - expect(component.errorMsg()).toBe('Fix the outlier report configuration before running.'); + const labels = Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('.outlier-variable-label')) + .map((label) => label.textContent?.trim()); + expect(labels).toEqual(['Age', 'BMI']); }); it('lists availability and preprocessing requirements together', async () => { @@ -911,9 +758,9 @@ describe('AlgorithmPanelComponent', () => { summary: 'Needs an outcome', details: [], }); - fixture.componentInstance.showOnlyActive.set(false); + catalog().showOnlyActive.set(false); fixture.detectChanges(); - expect(fixture.componentInstance.showOnlyActive()).toBe(false); + expect(catalog().showOnlyActive()).toBe(false); // The group holds the selected method, so it is open even with nothing runnable. const tile = (fixture.nativeElement as HTMLElement).querySelector('.algo-tile'); @@ -976,7 +823,7 @@ describe('AlgorithmPanelComponent', () => { expect(root.querySelector('.algo-tile')).toBeTruthy(); expect(root.querySelector('.documentation-panel')).toBeNull(); - fixture.componentInstance.onAlgorithmClick(algorithm); + chooseAlgorithm(algorithm); fixture.detectChanges(); await fixture.whenStable(); @@ -992,7 +839,7 @@ describe('AlgorithmPanelComponent', () => { }); it('renders setup and configuration in one unified card', () => { - fixture.componentInstance.onAlgorithmClick(algorithm); + chooseAlgorithm(algorithm); fixture.detectChanges(); const root = fixture.nativeElement as HTMLElement; @@ -1025,7 +872,7 @@ describe('AlgorithmPanelComponent', () => { expect(root.querySelector('.algorithm-details')).toBeNull(); expect(column?.contains(params ?? null)).toBe(true); - fixture.componentInstance.onAlgorithmClick(algorithm); + chooseAlgorithm(algorithm); fixture.detectChanges(); await fixture.whenStable(); @@ -1056,7 +903,7 @@ describe('AlgorithmPanelComponent', () => { const disabledAlgorithm = { ...algorithm, isDisabled: true, label: 'Locked Algorithm' }; experimentStudioService.availableGroupedAlgorithms.set({ Test: [disabledAlgorithm] }); - fixture.componentInstance.showOnlyActive.set(false); + catalog().showOnlyActive.set(false); fixture.detectChanges(); fixture.detectChanges(); @@ -1071,7 +918,7 @@ describe('AlgorithmPanelComponent', () => { modeTabs()[0].click(); fixture.detectChanges(); - expect(fixture.componentInstance.showOnlyActive()).toBe(true); + expect(catalog().showOnlyActive()).toBe(true); expect(modeTabs()[0].getAttribute('aria-pressed')).toBe('true'); expect(modeTabs()[0].textContent).toContain('Runnable'); expect(root.querySelector('.algo-tile')).toBeNull(); @@ -1086,13 +933,13 @@ describe('AlgorithmPanelComponent', () => { const root = fixture.nativeElement as HTMLElement; expect(root.querySelectorAll('.algo-tile').length).toBe(2); - fixture.componentInstance.algoSearchQuery.set('kruskal'); + catalog().algoSearchQuery.set('kruskal'); fixture.detectChanges(); const tiles = root.querySelectorAll('.algo-tile'); expect(tiles.length).toBe(1); expect(tiles[0].textContent).toContain('Kruskal-Wallis'); - fixture.componentInstance.algoSearchQuery.set('no-such-method'); + catalog().algoSearchQuery.set('no-such-method'); fixture.detectChanges(); expect(root.querySelector('.algo-tile')).toBeNull(); expect(root.textContent).toContain('No methods match'); @@ -1118,7 +965,7 @@ describe('AlgorithmPanelComponent', () => { Association: [association], }); // These specs study category expansion over the full catalog, so opt into All mode. - fixture.componentInstance.showOnlyActive.set(false); + catalog().showOnlyActive.set(false); fixture.detectChanges(); return { groupComparison: [anova, kruskal], association }; @@ -1169,7 +1016,7 @@ describe('AlgorithmPanelComponent', () => { fixture.detectChanges(); expect(rows[0].querySelector('.algo-tile')).toBeNull(); - fixture.componentInstance.onAlgorithmClick(groupComparison[1]); + chooseAlgorithm(groupComparison[1]); fixture.detectChanges(); await fixture.whenStable(); @@ -1183,16 +1030,6 @@ describe('AlgorithmPanelComponent', () => { expect(root.querySelector('app-algorithm-role-assignment')).toBeTruthy(); }); - it('exposes y/x role selections to the availability and config helpers', () => { - experimentStudioService.algorithmY.set([{ code: 'age', label: 'Age' }]); - experimentStudioService.algorithmX.set([{ code: 'sex', label: 'Sex' }]); - fixture.detectChanges(); - - const component = fixture.componentInstance; - expect(component.yVar()).toBe('age'); - expect(component.xVar()).toBe('sex'); - }); - it('keeps parameters off Setup until a method is chosen', () => { experimentStudioService.selectedAlgorithm.set(null); fixture.detectChanges(); @@ -1202,7 +1039,7 @@ describe('AlgorithmPanelComponent', () => { expect(root.querySelector('.algorithm-readonly-fieldset')).toBeNull(); expect(root.querySelector('.documentation-panel')).toBeNull(); - fixture.componentInstance.onAlgorithmClick(algorithm); + chooseAlgorithm(algorithm); fixture.detectChanges(); expect(root.querySelector('.documentation-panel')).toBeTruthy(); @@ -1235,6 +1072,6 @@ describe('AlgorithmPanelComponent', () => { (label) => label.textContent?.trim(), ); expect(labels).toEqual(expect.arrayContaining(['First optional', 'Second optional', 'Third optional', 'Fourth optional'])); - expect(component.configForm().get('first')?.value).toBe(1); + expect(configForm().configForm().get('first')?.value).toBe(1); }); }); diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts index aaf4f23a..2a3b4289 100644 --- a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.component.ts @@ -1,1703 +1,62 @@ -import { SessionStorageService } from './../../../services/session-storage.service'; -import { ChangeDetectionStrategy, Component, inject, signal, computed, effect, untracked, output } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { outputFromObservable } from '@angular/core/rxjs-interop'; import { ExperimentStudioService } from '../../../services/experiment-studio.service'; -import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; -import { buildFormControl } from '../../shared/utils/form-control.factory'; -import { getOutputSchema, prettifyLabel as toHumanLabel } from '../../../core/algorithm-mappers'; -import { formatInputCountRange } from '../../../core/algorithm-input-counts'; -import { - omitEmptyOptionalParameters, - optionBindingValue, - serializeAlgorithmParameterValue, -} from '../../../core/algorithm-parameter.utils'; -import { AlgorithmAvailabilityDetail, AlgorithmConfig } from '../../../models/algorithm-definition.model'; -import { experimentTables, PdfExportService } from '../../../services/pdf-export.service'; -import { CsvExportService } from '../../../services/csv-export.service'; -import { formatFilterExpression } from '../../../core/filter-display.utils'; -import { ErrorService } from '../../../services/error.service'; -import { AuthService } from '../../../services/auth.service'; -import { AlgorithmNames, VariableTypes } from '../../../core/constants/algorithm.constants'; -import { RuntimeEnvService } from '../../../services/runtime-env.service'; -import { ExperimentStudioNavigationService } from '../../../services/experiment-studio-navigation.service'; -import { - createDefaultOutlierRule, - defaultFoldForStrategy, - hydrateOutlierRules, - isOutlierEligibleVariable, - OUTLIER_STRATEGIES, - OUTLIER_TAILS, - OutlierRule, - OutlierStrategy, - OutlierTail, - serializeOutlierRules, - validateOutlierRule, -} from '../../../core/outlier-rules'; -import { AlgorithmRoleAssignmentComponent } from './algorithm-role-assignment/algorithm-role-assignment.component'; -import { unavailableDatasetsFor, unavailableVariableTooltip } from '../../../core/variable-availability.utils'; - -type AlgorithmStudioSubstep = 'setup' | 'parameters'; - -type AlgorithmRunRequirementKind = 'availability' | 'preprocessing'; - -interface AlgorithmRunRequirement { - id: AlgorithmRunRequirementKind; - message: string; - actionLabel: string; -} - -interface AlgorithmNeedItem { - key: string; - met: boolean; - title: string; - messages: string[]; - action?: AlgorithmRunRequirement; -} - +import { AlgorithmSelectionComponent } from './algorithm-selection/algorithm-selection.component'; +import { AlgorithmCatalogService } from './algorithm-selection/algorithm-catalog.service'; +import { ExperimentResultComponent } from './experiment-result/experiment-result.component'; +import { AlgorithmConfigFormService } from './algorithm-selection/algorithm-config-form.service'; +import { AlgorithmRunService } from './experiment-result/algorithm-run.service'; +import { AlgorithmStudioSubstepService } from './algorithm-studio-substep.service'; +import { AlgorithmStudioSubstep } from './algorithm-panel.models'; + +/** + * The studio's Algorithm step. A shell: it provides the feature services its sections share, + * lays out the algorithm card and its readiness bar, and keeps the API the studio calls + * (run state for the run button, the run itself, the exports and the sub-step). + */ @Component({ selector: 'app-algorithm-panel', - imports: [ - CommonModule, - FormsModule, - ReactiveFormsModule, - AlgorithmRoleAssignmentComponent, + imports: [AlgorithmSelectionComponent, ExperimentResultComponent], + providers: [ + AlgorithmStudioSubstepService, + AlgorithmCatalogService, + AlgorithmConfigFormService, + AlgorithmRunService, ], templateUrl: './algorithm-panel.component.html', styleUrl: './algorithm-panel.component.css', changeDetection: ChangeDetectionStrategy.OnPush, }) - export class AlgorithmPanelComponent { - pdfExport = inject(PdfExportService); - private readonly csvExport = inject(CsvExportService); - private errorService = inject(ErrorService); - private authService = inject(AuthService); - private runtimeEnvService = inject(RuntimeEnvService); - private studioNavigation = inject(ExperimentStudioNavigationService); - experimentStudioService = inject(ExperimentStudioService); - sessionStorage = inject(SessionStorageService); - lastUsedAlgorithm = ''; - errorMsg = signal(null); - readonly isRunning = this.experimentStudioService.isRunning; + private readonly substep = inject(AlgorithmStudioSubstepService); + protected readonly run = inject(AlgorithmRunService); + /** The card shows once the studio has variables to assign. */ + protected readonly assignablePool = inject(ExperimentStudioService).algorithmAssignableVariables; /** Studio: setup (roles + methods), then parameters (configure + run). Results live in the Execution step. */ - readonly studioSubstep = signal('setup'); - readonly studioSubstepChange = output(); - - setStudioSubstep(step: AlgorithmStudioSubstep): void { - this.studioSubstep.set(step); - this.studioSubstepChange.emit(step); - } - - readonly mipVersion = this.runtimeEnvService.mipVersion; - - readonly selectedAlgorithm = this.experimentStudioService.selectedAlgorithm; - readonly selectedAlgorithmDocumentation = computed(() => this.selectedAlgorithm()?.documentation?.trim() ?? ''); - readonly selectedAlgorithmUnavailable = computed(() => { - const algorithm = this.selectedAlgorithm(); - return !!algorithm && !this.experimentStudioService.isAlgorithmAvailable(algorithm.name); - }); - readonly selectedAvailabilityDetails = computed(() => this.availabilityDetails(this.selectedAlgorithm())); - - readonly algorithmNeeds = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return []; - - const needs: AlgorithmNeedItem[] = []; - const details = this.selectedAvailabilityDetails(); - const detailByRole = new Map(details.map((detail) => [detail.role, detail])); - - for (const role of ['y', 'x'] as const) { - const detail = detailByRole.get(role); - if (detail) { - needs.push({ - key: `needs-${role}`, - met: detail.messages.length === 0, - title: this.needsRequirementText(detail), - messages: [...detail.messages], - }); - } else if (role === 'y' && !('y' in (algorithm.inputdata ?? {}))) { - needs.push({ - key: 'needs-y', - met: true, - title: 'No outcome needed', - messages: this.experimentStudioService.algorithmY().length ? ['Your outcome is ignored'] : [], - }); - } - } - - const unavailable = this.unavailableRunVariablesReason(); - if (unavailable) { - needs.push({ key: 'unavailable-variables', met: false, title: unavailable, messages: [] }); - } - - this.algorithmRunRequirements().forEach((requirement) => { - needs.push({ - key: `run-${requirement.id}`, - met: false, - title: requirement.message, - messages: [], - action: requirement, - }); - }); - - return needs; - }); - - needsRequirementText(detail: AlgorithmAvailabilityDetail): string { - const typeText = this.requirementTypeText(detail.types); - - if (detail.role === 'y') { - if (detail.minCount <= 0 && !detail.required) { - return 'No outcome needed'; - } - const article = typeText - ? `${/^[aeiou]/i.test(typeText) ? 'an' : 'a'} ${typeText} ` - : ''; - const noun = detail.minCount > 1 ? 'outcomes' : 'outcome'; - return detail.minCount > 1 - ? `Needs at least ${detail.minCount} ${noun}` - : `Needs ${article}${noun}`; - } - - const noun = detail.minCount > 1 ? 'predictors' : 'predictor'; - if (detail.minCount > 0) { - return `At least ${detail.minCount}${typeText ? ` ${typeText}` : ''} ${noun}`; - } - if (detail.maxCount !== null) { - return `Up to ${detail.maxCount} ${noun}`; - } - return 'Predictors optional'; - } - - private requirementTypeText(types: string[] | undefined | null): string { - if (!Array.isArray(types) || types.length === 0) return ''; - const labels = new Set(); - types.forEach((type) => { - const normalized = String(type ?? '').trim().toLowerCase(); - if (!normalized) return; - if (['real', 'int', 'integer', 'numeric', 'numerical'].includes(normalized)) { - labels.add('numerical'); - } else if ( - ['text', 'nominal', 'categorical', 'polynominal', 'ordinal', 'string'].includes(normalized) - ) { - labels.add('categorical'); - } else { - labels.add(normalized); - } - }); - return Array.from(labels).join(' or '); - } - - readonly readinessText = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return 'Pick a method to continue'; - - if (!this.canRun()) { - const reasons = this.algorithmNeeds() - .filter((need) => !need.met) - .flatMap((need) => (need.action ? [need.title] : need.messages)) - .filter((message) => !!message); - if (!reasons.length) return 'Not ready to run yet'; - return `${reasons.length} ${reasons.length === 1 ? 'thing' : 'things'} left — ${reasons.slice(0, 2).join(' · ')}${reasons.length > 2 ? ` +${reasons.length - 2}` : ''}`; - } - - const y = this.experimentStudioService.algorithmY(); - const x = this.experimentStudioService.algorithmX(); - const outcome = y[0] ? ` · y ${y[0].label ?? y[0].name ?? y[0].code}` : ''; - const predictors = ` · ${x.length} ${x.length === 1 ? 'predictor' : 'predictors'}`; - return `Ready · ${algorithm.label}${outcome}${predictors}`; - }); - - categoryRunnableCount(category: { algorithms: AlgorithmConfig[] }): number { - return category.algorithms.filter((algorithm) => !algorithm.isDisabled).length; - } - - readonly selectedAlgorithmAvailabilitySummary = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm || !this.selectedAlgorithmUnavailable()) return ''; - const summary = this.experimentStudioService.getAlgorithmAvailability(algorithm.name).summary ?? algorithm.availability?.summary ?? ''; - return this.formatAvailabilityMessage(summary); - }); - readonly algorithmRunRequirements = computed((): AlgorithmRunRequirement[] => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return []; - - const requirements: AlgorithmRunRequirement[] = []; - - if (this.selectedAlgorithmUnavailable()) { - const summary = this.selectedAlgorithmAvailabilitySummary(); - requirements.push({ - id: 'availability', - message: summary || 'Algorithm requirements are not satisfied.', - actionLabel: this.availabilityRequirementActionLabel(algorithm.name), - }); - } - - const preprocessingMessage = this.preprocessingRunBlockMessage(); - if (preprocessingMessage) { - requirements.push({ - id: 'preprocessing', - message: preprocessingMessage, - actionLabel: 'Go to preprocessing', - }); - } - - return requirements; - }); - readonly selectedAlgorithmCategory = computed(() => this.selectedAlgorithm()?.category?.trim() ?? ''); - readonly yVar = computed(() => this.experimentStudioService.algorithmY()[0]?.code ?? null); - readonly xVar = computed(() => this.experimentStudioService.algorithmX()[0]?.code ?? null); - readonly crossValidationEnabled = signal(false); - private readonly crossValidationSelections: Record = {}; - readonly isCrossValidationOnly = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return false; - return this.experimentStudioService.isCrossValidationOnly(algorithm.name); - }); - readonly outlierStrategies = OUTLIER_STRATEGIES; - readonly outlierTails = OUTLIER_TAILS; - outlierReportRules: Record = {}; - outlierReportValidationErrors: Record = {}; - readonly canToggleCrossValidation = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return false; - if (this.experimentStudioService.isCrossValidationOnly(algorithm.name)) return false; - const baseName = this.experimentStudioService.getCrossValidationBase(algorithm.name) ?? algorithm.name; - return ( - !!this.experimentStudioService.getCrossValidationVariant(baseName) && - !!this.experimentStudioService.backendAlgorithms()[baseName] - ); - }); - readonly isOutlierReportSelected = computed( - () => this.selectedAlgorithm()?.name === AlgorithmNames.OUTLIER_REPORT - ); - readonly hasAlgorithmParameterControls = computed(() => ( - this.visibleConfigSchema().length > 0 || - this.isOutlierReportSelected() || - this.canToggleCrossValidation() || - this.isCrossValidationOnly() - )); - readonly preprocessingRunBlockMessage = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return null; - if (algorithm.name === AlgorithmNames.OUTLIER_REPORT) return null; - if ( - this.experimentStudioService.hasAppliedDescriptivePreprocessing() || - this.experimentStudioService.hasRequestPreprocessingForRun(algorithm.name) - ) return null; - return 'Apply missing value preprocessing before running algorithms.'; - }); - private readonly formInvalid = signal(false); - /** - * "Remove variables not available in: …" when a variable the run uses (outcome, predictor - * or cohort filter) is missing from a selected dataset; '' otherwise. Such variables stay - * in the pool grayed out, so the run is blocked until the user removes them. - */ - readonly unavailableRunVariablesReason = computed(() => { - const map = this.experimentStudioService.unavailableVariables(); - if (!map.size) return ''; - const datasets = new Set(); - for (const node of [ - ...this.experimentStudioService.algorithmY(), - ...this.experimentStudioService.algorithmX(), - ...this.experimentStudioService.selectedFilters(), - ]) { - unavailableDatasetsFor(map, { code: node?.code }).forEach((label) => datasets.add(label)); - } - return unavailableVariableTooltip([...datasets], 'Remove variables not available in'); - }); - readonly canRun = computed(() => ( - !!this.selectedAlgorithm() && - !this.isRunning() && - !this.selectedAlgorithmUnavailable() && - !this.unavailableRunVariablesReason() && - !this.preprocessingRunBlockMessage() && - !this.formInvalid() - )); - readonly runDisabledReason = computed(() => { - if (this.isRunning()) return 'Experiment is running'; - if (!this.selectedAlgorithm()) return 'Select an algorithm to run'; - if (this.selectedAlgorithmUnavailable()) return 'Algorithm unavailable for the current selection'; - const unavailable = this.unavailableRunVariablesReason(); - if (unavailable) return unavailable; - const preprocessing = this.preprocessingRunBlockMessage(); - if (preprocessing) return preprocessing; - if (this.formInvalid()) return 'Complete variable and algorithm selection to proceed'; - return 'Execute experiment'; - }); - readonly labelMap = this.experimentStudioService.variableLabelMap; - - // UI toggle: the catalog defaults to runnable methods only — each group lists just the - // available algorithms. Switching to All re-lists unavailable methods with their reason. - readonly showOnlyActive = signal(true); - /** Instant text filter over label, name, description, documentation, category, and flags. */ - readonly algoSearchQuery = signal(''); - readonly hasAnyVisibleAlgorithms = computed(() => { - return this.filteredAlgorithmCategories().some(c => c.algorithms?.length > 0); - }); - - prettyFieldLabel(field: any): string { - let base = field.label ?? field.key; - if ((field.key === 'groupA' || field.key === 'groupB')) { - const x = this.experimentStudioService.algorithmX(); - const cov = x[0]; - const covName = cov?.label || cov?.name || cov?.code; - if (covName) base = `${base} (${covName})`; - } - return base; - } - - private variableOptionsForRoles(roles: string[]): Array<{ code: string; label: string }> { - const roleList = roles.length ? roles : ['x']; - return roleList.flatMap((role) => { - const selected = - role === 'y' - ? this.experimentStudioService.algorithmY() - : role === 'x' - ? this.experimentStudioService.algorithmX() - : []; - - return selected - .map((item: any) => { - const code = item?.code; - if (code === null || code === undefined || code === '') return null; - return { - code: String(code), - label: String(item?.label ?? item?.name ?? code), - }; - }) - .filter((item): item is { code: string; label: string } => !!item); - }); - } - private enumOptionsForRole(role: string | null | undefined): any[] { - const selected = - role === 'x' - ? this.experimentStudioService.algorithmX()[0] - : this.experimentStudioService.algorithmY()[0]; - return Array.isArray(selected?.enumerations) ? [...selected.enumerations] : []; - } - - private resolveSelectedEventVarCode(algorithmName: string | undefined): string | null { - if (!algorithmName) return null; - - const live = this.eventVarSelection(); - if (live !== null && live !== undefined && String(live).trim() !== '') { - return String(live); - } - - const stored = untracked(() => this.experimentStudioService.algorithmConfigurations())[ - algorithmName - ]?.['event_var']; - if (stored !== undefined && stored !== null && String(stored).trim() !== '') { - return String(stored); - } - - return null; - } - - private buildConfigSchemaSignature( - algorithmName: string, - schema: Array<{ key?: string; type?: string; required?: boolean; options?: unknown[] }> - ): string { - return JSON.stringify({ - algorithmName, - crossValidation: this.crossValidationEnabled(), - fields: schema.map((field) => ({ - key: field.key, - type: field.type, - required: field.required, - options: (field.options ?? []).map((opt) => this.optionValue(opt)), - })), - }); - } - - private syncEventVarSelectionFromValues( - algorithmName: string, - values: Record - ): void { - if (!Object.prototype.hasOwnProperty.call(values, 'event_var')) return; - const raw = values['event_var']; - const next = - raw !== null && raw !== undefined && String(raw).trim() !== '' ? String(raw) : null; - if (this.eventVarSelection() === next) return; - this.eventVarSelection.set(next); - this.syncPositiveClassWithEventVar(algorithmName); - } - - private syncEventVarSelectionFromStored(stored: Record): void { - const raw = stored['event_var']; - const next = - raw !== null && raw !== undefined && String(raw).trim() !== '' ? String(raw) : null; - this.eventVarSelection.set(next); - } - - private enumOptionsForVariableCode( - varCode: string | null | undefined - ): Array<{ code: string; label: string }> { - if (!varCode) return []; - - const variables = [ - ...this.experimentStudioService.algorithmY(), - ...this.experimentStudioService.algorithmX(), - ]; - const variable = variables.find((item) => String(item?.code) === String(varCode)); - if (!variable) return []; - - const enumerations = Array.isArray(variable.enumerations) ? variable.enumerations : []; - const seen = new Set(); - - return enumerations - .map((entry: any) => { - const code = entry?.code ?? entry?.value ?? entry?.name ?? entry?.label; - if (code === null || code === undefined || String(code).trim() === '') { - return null; - } - const normalizedCode = String(code); - if (seen.has(normalizedCode)) return null; - seen.add(normalizedCode); - return { - code: normalizedCode, - label: String(entry?.label ?? entry?.name ?? normalizedCode), - }; - }) - .filter( - (item: { code: string; label: string } | null): item is { code: string; label: string } => - !!item - ); - } - - /** Live event_var from the config form; avoids reading FormGroup inside other computeds. */ - private readonly eventVarSelection = signal(null); - - readonly selectedEventVarCode = computed(() => { - const algorithm = this.selectedAlgorithm(); - return algorithm ? this.resolveSelectedEventVarCode(algorithm.name) : null; - }); - - readonly positiveClassOptions = computed(() => - this.enumOptionsForVariableCode(this.selectedEventVarCode()) - ); - - private isCoxRegressionAlgorithm(algorithmName: string | null | undefined): boolean { - return algorithmName === 'cox_regression_classical' || algorithmName === 'cox_regression_stacked'; - } - - private optionValue(opt: any): any { - if (opt && typeof opt === 'object') { - return opt.code ?? opt.value ?? opt.name ?? opt.label ?? String(opt); - } - return opt; - } - - // sensible defaults - private readonly uiDefaults: Record = { - alpha: 0.05, - alt_hypothesis: 'two-sided', - mu: 0.0, - n_splits: 5, - k: 4, - tol: 0.01, - sstype: 2, - conf_level: 0.95, - iterations: 1000, - }; - - constructor() { - effect(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) { - this.crossValidationEnabled.set(false); - return; - } - if (this.experimentStudioService.isCrossValidationOnly(algorithm.name)) { - this.crossValidationEnabled.set(true); - return; - } - const baseName = this.experimentStudioService.getCrossValidationBase(algorithm.name) ?? algorithm.name; - const hasVariant = !!this.experimentStudioService.getCrossValidationVariant(baseName); - if (!hasVariant) { - this.crossValidationEnabled.set(false); - return; - } - const stored = this.crossValidationSelections[baseName]; - const defaultValue = stored !== undefined - ? stored - : this.experimentStudioService.isCrossValidationAlgorithm(algorithm.name); - this.crossValidationEnabled.set(defaultValue); - }); - - effect(() => { - // Establish dependencies - this.selectedAlgorithm(); - this.experimentStudioService.algorithmY(); - this.experimentStudioService.algorithmX(); - - // A new selection invalidates whatever the last run produced - this.experimentStudioService.runResult.set(null); - this.experimentStudioService.runError.set(null); - }); - - effect(() => { - const algorithm = this.selectedAlgorithm(); - const variables = this.outlierReportVariables(); - if (!algorithm || algorithm.name !== AlgorithmNames.OUTLIER_REPORT) { - this.outlierReportValidationErrors = {}; - return; - } - const stored = untracked(() => this.experimentStudioService.algorithmConfigurations()[algorithm.name] ?? {}); - this.reconcileOutlierReportRules(variables, stored); - }); - - effect((onCleanup) => { - const algorithm = this.selectedAlgorithm(); - const schema = this.visibleConfigSchema(); - - if (!algorithm) { - this.lastBuiltFormSchemaSignature = ''; - this.eventVarSelection.set(null); - this.configForm.set(new FormGroup({})); - this.formInvalid.set(false); - return; - } - - const schemaSignature = this.buildConfigSchemaSignature(algorithm.name, schema); - if (schemaSignature === this.lastBuiltFormSchemaSignature) { - return; - } - this.lastBuiltFormSchemaSignature = schemaSignature; - - const allConfigs = untracked(() => this.experimentStudioService.algorithmConfigurations()); - const stored = allConfigs?.[algorithm.name] || {}; - this.syncEventVarSelectionFromStored(stored); - - const group: { [key: string]: FormControl } = {}; - - schema.forEach((field) => { - // backend default - let backendDefault = - field.default !== undefined ? field.default : null; - const storedValue = stored?.[field.key]; - - // special treatment for alpha - if ((backendDefault === null || backendDefault === undefined) && field.key === 'alpha') { - backendDefault = 0.05; - } - - // fallback value logic - const fallback = - storedValue !== undefined - ? storedValue - : field.default !== undefined && field.default !== null - ? field.default - : backendDefault !== undefined && backendDefault !== null - ? backendDefault - : this.uiDefaults[field.key] !== undefined - ? this.uiDefaults[field.key] - : field.type === 'checkbox' - ? false - : ''; - - // label if missing - const label = - field.label && field.label.trim() !== '' - ? field.label - : toHumanLabel(field.key); - - const prettyField = { - ...field, - label, - desc: field.desc ?? field.description ?? '', - }; - - const control = buildFormControl(prettyField, fallback); - - // extra validation: integer-only for numeric fields - const isNumeric = prettyField.type === 'number'; - const allowsDecimal = - prettyField.types?.includes('real') || - prettyField.key === 'alpha' || - prettyField.key === 'tol' || - prettyField.key === 'mu'; - - if (isNumeric && !allowsDecimal) { - const integerPattern = Validators.pattern(/^-?\d+$/); - const existingValidator = control.validator; - - control.setValidators( - existingValidator ? [existingValidator, integerPattern] : [integerPattern] - ); - - // stop events while building form - control.updateValueAndValidity({ emitEvent: false }); - } - - group[field.key] = control; - }); - - const form = new FormGroup(group, { updateOn: 'change' }); - this.configForm.set(form); - this.formInvalid.set(form.invalid); - - Object.values(this.configForm().controls).forEach(control => { - if (control.valid) { - control.markAsTouched({ onlySelf: true }); - } - }); - - const statusSub = form.statusChanges.subscribe(() => this.formInvalid.set(form.invalid)); - const subscription = form.valueChanges.subscribe((values) => { - this.syncEventVarSelectionFromValues(algorithm.name, values); - this.persistCurrentFormConfig( - algorithm.name, - untracked(() => this.visibleConfigSchema()) - ); - }); - onCleanup(() => { - subscription.unsubscribe(); - statusSub.unsubscribe(); - }); - - this.formKey++; - }); - effect(() => { - const res = this.experimentStudioService.runResult(); - const schema = this.outputSchema(); - - if (!res || !schema.length) return; - - const missing = schema.filter(field => !(field.key in res)); - if (missing.length > 0) { - console.warn('[Validation] Missing fields from result:', missing.map(f => f.key)); - } - }); - - effect(() => { - const categories = this.filteredAlgorithmCategories(); - const selectedName = this.selectedAlgorithm()?.name ?? null; - untracked(() => this.syncMatchCategoryOpen(categories, selectedName)); - }); - } - - - configForm = signal(new FormGroup({})); - formKey = 0; - private lastBuiltFormSchemaSignature = ''; - - enrichedConfigSchema = computed(() => { - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return []; - - // Basic algorithm schema (shallow copy) - const schema = (algorithm.configSchema ?? []).map(f => ({ ...f })); - - const yVars = this.experimentStudioService.algorithmY(); - const xVars = this.experimentStudioService.algorithmX(); - const yVar = yVars[0]; - const xVar = xVars[0]; - const eventVarCode = this.selectedEventVarCode(); - const positiveClassOptions = this.enumOptionsForVariableCode(eventVarCode); - const isCox = this.isCoxRegressionAlgorithm(algorithm.name); - const enriched = schema.map(field => { - let options = field.options ?? []; - const enumSource = Array.isArray(field.enumSource) ? field.enumSource : []; - - if (field.enumType === 'input_var_names') { - options = this.variableOptionsForRoles(enumSource); - } else if (field.enumType === 'input_var_CDE_enums') { - options = this.enumOptionsForRole(enumSource[0]); - } else if (field.key === 'category_order' && yVar?.enumerations?.length) { - options = [...yVar.enumerations]; - } else if (field.key === 'positive_class' && positiveClassOptions.length) { - options = positiveClassOptions; - } - - // Placeholder substitution for enums - if (Array.isArray(options) && options.length === 1) { - const placeholder = options[0]; - - if (placeholder === 'y' && yVar?.enumerations?.length) { - options = [...yVar.enumerations]; - } else if (placeholder === 'x' && xVar?.enumerations?.length) { - options = [...xVar.enumerations]; - } - } - - // Fallback for empty select fields (not input_var_names — those list y/x variables) - if ( - field.type === 'select' && - field.enumType !== 'input_var_names' && - (!options || options.length === 0) && - yVar?.enumerations?.length - ) { - options = [...yVar.enumerations]; - } - - const defaultValue = field.key === 'category_order' && Array.isArray(options) && options.length > 0 - ? options.map((opt: any) => this.optionValue(opt)) - : field.default; - - // Normalize label and desc - const label = field.label?.trim() || toHumanLabel(field.key); - const desc = - field.key === 'positive_class' && isCox - ? 'Event level mapped to 1; other observed levels are mapped to 0.' - : field.desc ?? field.description ?? ''; - const type = - field.key === 'positive_class' && positiveClassOptions.length - ? 'select' - : field.type; - const required = - (field.key === 'positive_class' && isCox) || - field.required === true || - String(field.required ?? '').trim().toLowerCase() === 'true'; - - // Return enriched field - return { ...field, label, desc, options, default: defaultValue, type, required }; - }); - - if (this.crossValidationEnabled() && this.canToggleCrossValidation()) { - const hasSplits = enriched.some((field) => field.key === 'n_splits'); - if (!hasSplits) { - enriched.push({ - key: 'n_splits', - label: 'Folds', - desc: 'Number of parts the data is split into; each part is held out once.', - type: 'number', - min: 2, - default: 5, - }); - } - } - - return enriched; - }); - - readonly visibleConfigSchema = computed(() => { - const schema = this.enrichedConfigSchema(); - if (!this.isOutlierReportSelected()) return schema; - return schema.filter((field) => field.type !== 'dict' && !['strategies', 'tails', 'folds'].includes(String(field.key))); - }); - - /** Backend metadata has no advanced flag yet; the heuristic mirrors the review handoff. */ - isAdvancedField(field: any): boolean { - return field?.advanced ?? (field?.default !== undefined && !field?.required); - } - - /** n_splits belongs to the cross-validation row, so it renders under that switch, not in the main form. */ - isCrossValidationField(field: any): boolean { - return field?.key === 'n_splits' && (this.canToggleCrossValidation() || this.isCrossValidationOnly()); - } - - readonly primaryConfigSchema = computed(() => - this.visibleConfigSchema().filter((field) => - !this.isCrossValidationField(field) && !this.isAdvancedField(field) && this.configForm().get(field.key)) - ); - - readonly advancedConfigSchema = computed(() => - this.visibleConfigSchema().filter((field) => - !this.isCrossValidationField(field) && this.isAdvancedField(field) && this.configForm().get(field.key)) - ); - - readonly crossValidationConfigSchema = computed(() => - this.crossValidationEnabled() - ? this.visibleConfigSchema().filter((field) => this.isCrossValidationField(field) && this.configForm().get(field.key)) - : [] - ); - - readonly outlierReportVariables = computed(() => { - const unique = new Map(); - this.experimentStudioService.algorithmAssignableVariables().forEach((variable: any) => { - if (variable?.code) unique.set(String(variable.code), variable); - }); - return Array.from(unique.values()).filter((variable) => isOutlierEligibleVariable(variable)); - }); - - readonly outputSchema = computed(() => - getOutputSchema(this.selectedAlgorithm()?.name ?? '') ?? [] - ); - - readonly searchActive = computed(() => this.algoSearchQuery().trim().length > 0); - - private matchesAlgorithmSearch(algorithm: AlgorithmConfig, query: string): boolean { - if (!query) return true; - const haystack = [ - algorithm.label, - algorithm.name, - algorithm.description, - algorithm.documentation ?? '', - algorithm.category ?? '', - (algorithm.flags ?? []).join(' '), - ].join(' ').toLowerCase(); - return query.split(/\s+/).every((term) => haystack.includes(term)); - } - - readonly filteredAlgorithmCategories = computed(() => { - const grouped = this.experimentStudioService.availableGroupedAlgorithms(); - const onlyActive = this.showOnlyActive(); - const query = this.algoSearchQuery().trim().toLowerCase(); - - const entries = Object.entries(grouped ?? {}).map(([name, algorithms]) => { - let filtered = (algorithms ?? []).filter(a => this.matchesAlgorithmSearch(a, query)); - if (onlyActive) { - filtered = filtered.filter(a => !a.isDisabled); - } - - // Runnable-first: when showing all methods, surface runnable ones before disabled ones. - const ordered = onlyActive - ? filtered - : [...filtered].sort((a, b) => Number(a.isDisabled) - Number(b.isDisabled)); - - return { name, algorithms: ordered }; - }); - - return entries.filter(c => c.algorithms.length > 0); - }); - - readonly algoCounts = computed(() => { - const grouped = this.experimentStudioService.availableGroupedAlgorithms(); - const all = Object.values(grouped ?? {}).flat(); - const active = all.filter(a => !a.isDisabled); - return { all: all.length, active: active.length }; - }); - - toggleActiveOnly() { - this.showOnlyActive.update(v => !v); - } - - onSearchInput(event: Event): void { - this.algoSearchQuery.set((event.target as HTMLInputElement)?.value ?? ''); - } - - clearSearch(): void { - this.algoSearchQuery.set(''); - } - - /** - * Collapsed Matching-method categories. A category missing from this set is open, so - * categories coming back after a filter switch keep the state the user left them in. - */ - private readonly collapsedMatchCategories = signal>(new Set()); - /** Categories the user opened/closed themselves; these are never changed automatically. */ - private readonly matchCategoryUserToggles = new Set(); - private lastMatchSelectionName: string | null | undefined; - - isMatchCategoryOpen(categoryName: string): boolean { - return !this.collapsedMatchCategories().has(categoryName); - } - - toggleMatchCategory(categoryName: string): void { - this.matchCategoryUserToggles.add(categoryName); - const next = new Set(this.collapsedMatchCategories()); - if (next.has(categoryName)) { - next.delete(categoryName); - } else { - next.add(categoryName); - } - this.collapsedMatchCategories.set(next); - } - - /** - * Default state: a group is open only when it holds the selected method or at least one - * runnable method — with nothing runnable in a group it stays collapsed so the catalog - * opens straight to what can actually be executed. Beyond that default, the group a new - * selection lands in is revealed; groups the user toggled are never changed automatically. - */ - private syncMatchCategoryOpen( - categories: Array<{ name: string; algorithms: AlgorithmConfig[] }>, - selectedName: string | null, - ): void { - const selectionChanged = this.lastMatchSelectionName !== selectedName; - this.lastMatchSelectionName = selectedName; - - const selectedCategory = selectedName - ? categories.find((category) => - category.algorithms.some((algorithm) => algorithm.name === selectedName))?.name - : null; - - const next = new Set(this.collapsedMatchCategories()); - let changed = false; - - categories.forEach((category) => { - if (this.matchCategoryUserToggles.has(category.name)) return; - const shouldOpen = - category.name === selectedCategory || - category.algorithms.some((algorithm) => !algorithm.isDisabled); - if (shouldOpen === !next.has(category.name)) return; - if (shouldOpen) { - next.delete(category.name); - } else { - next.add(category.name); - } - changed = true; - }); + readonly studioSubstep = this.substep.current; + readonly studioSubstepChange = outputFromObservable(this.substep.changed$); - if (selectedCategory && selectionChanged && next.delete(selectedCategory)) { - changed = true; - } + /** Read by the studio's run button. */ + readonly canRun = this.run.canRun; + readonly runDisabledReason = this.run.runDisabledReason; - if (changed) this.collapsedMatchCategories.set(next); - } - - algorithmUnavailableReason(algorithm: AlgorithmConfig): string { - const availability = this.experimentStudioService.getAlgorithmAvailability(algorithm.name); - const summary = availability?.summary || algorithm.availability?.summary || ''; - if (summary) return this.formatAvailabilityMessage(summary); - const details = availability?.details?.length - ? availability.details - : (algorithm.availability?.details ?? []); - const first = details.find((detail) => detail.messages.length > 0); - return first ? this.formatAvailabilityMessage(first.messages[0]) : 'Unavailable'; - } - - - readonly datasetsWithLabels = computed(() => { - const codes = this.experimentStudioService.selectedDatasets(); - const labelByCode = this.experimentStudioService.getDatasetLabelMap(); - const fallbackMap = this.labelMap(); - return codes.map((code) => ({ - code, - label: labelByCode[code] ?? fallbackMap[code] ?? toHumanLabel(code), - })); - }); - - readonly experimentInfo = computed(() => { - const selected = this.experimentStudioService.selectedAlgorithm(); - const lastRunName = - this.experimentStudioService.lastUsedAlgorithm() || this.lastUsedAlgorithm; - - const algoName = selected?.name || lastRunName || null; - - const algoCatalog = this.experimentStudioService.backendAlgorithms(); - const algoMeta = algoName ? algoCatalog[algoName] : null; - - const displayLabel = selected?.label || algoMeta?.label || algoName || 'N/A'; - const defaultName = `Experiment for ${displayLabel}`; - - const allConfigs = this.experimentStudioService.algorithmConfigurations(); - const configs = - (algoName && allConfigs[algoName]) || {}; - - const preprocessingSteps = this.experimentStudioService.formatPreprocessingEntries( - this.experimentStudioService.getAppliedDescriptivePreprocessing(), - this.labelMap(), - this.experimentStudioService.getCategoricalEnumMaps(), - ); - - return { - experimentName: defaultName, - variables: this.experimentStudioService.algorithmY(), - covariates: this.experimentStudioService.algorithmX(), - filters: this.experimentStudioService.selectedFilters(), - algorithmConfigs: configs, - preprocessingSteps, - }; - }); - - private syncPositiveClassWithEventVar(algorithmName: string): void { - const positiveControl = this.configForm().get('positive_class'); - if (!positiveControl) return; - - const options = this.enumOptionsForVariableCode(this.resolveSelectedEventVarCode(algorithmName)); - if (!options.length) return; - - const allowed = new Set(options.map((option) => option.code)); - const current = positiveControl.value; - if ( - current !== null && - current !== undefined && - String(current).trim() !== '' && - !allowed.has(String(current)) - ) { - positiveControl.setValue(null, { emitEvent: false }); - } - } - - private persistCurrentFormConfig(algorithmName: string, schema = this.visibleConfigSchema()): Record { - const values = this.normalizeFormValues(this.configForm().getRawValue(), schema); - const nextValues = algorithmName === AlgorithmNames.OUTLIER_REPORT - ? this.mergeOutlierReportConfig(values) - : values; - const allConfigs = this.experimentStudioService.algorithmConfigurations(); - const previous = allConfigs[algorithmName]; - if (previous && JSON.stringify(previous) === JSON.stringify(nextValues)) { - return nextValues; - } - this.experimentStudioService.algorithmConfigurations.set({ - ...allConfigs, - [algorithmName]: nextValues, - }); - return nextValues; - } - - bindOptionValue(option: unknown): string { - return optionBindingValue(option); - } - - private normalizeFormValues(values: Record, schema: any[]): Record { - const normalized = { ...values }; - schema - .filter((field) => field?.type === 'number') - .forEach((field) => { - const key = String(field.key); - const value = normalized[key]; - if (typeof value !== 'string') return; - const trimmed = value.trim(); - if (!trimmed) return; - const parsed = Number(trimmed); - if (Number.isFinite(parsed)) normalized[key] = parsed; - }); - - schema.forEach((field) => { - const key = String(field.key); - if (!(key in normalized)) return; - if (field?.type === 'select' || field?.type === 'multi-select' || key === 'positive_class') { - normalized[key] = serializeAlgorithmParameterValue(normalized[key], field); - } - }); - - return omitEmptyOptionalParameters(normalized, schema) as Record; - } - - private mergeOutlierReportConfig(configValues: Record): Record { - if (!this.isOutlierReportSelected()) return configValues; - delete configValues['strategies']; - delete configValues['tails']; - delete configValues['folds']; - const payload = serializeOutlierRules( - this.outlierReportRules, - new Set(this.outlierReportVariables().map((variable) => variable.code)) - ); - return payload ? { ...configValues, ...payload } : configValues; - } - - private persistOutlierReportConfig(): void { - const algorithm = this.selectedAlgorithm(); - if (!algorithm || algorithm.name !== AlgorithmNames.OUTLIER_REPORT) return; - const visibleValues = this.normalizeFormValues(this.configForm().getRawValue(), this.visibleConfigSchema()); - const nextConfig = this.mergeOutlierReportConfig(visibleValues); - this.experimentStudioService.algorithmConfigurations.set({ - ...this.experimentStudioService.algorithmConfigurations(), - [algorithm.name]: nextConfig, - }); - } - - private reconcileOutlierReportRules(variables: any[], stored: Record): void { - const allowedCodes = new Set(variables.map((variable) => variable.code)); - const hydrated = hydrateOutlierRules(stored, allowedCodes); - const hasStoredRules = !!stored && typeof stored === 'object' && Object.keys(stored?.['strategies'] ?? {}).length > 0; - const hasExistingRules = Object.keys(this.outlierReportRules).length > 0; - const next: Record = {}; - - variables.forEach((variable) => { - const code = variable.code; - next[code] = this.outlierReportRules[code] - ? { ...this.outlierReportRules[code] } - : hydrated[code] - ? { ...hydrated[code] } - : createDefaultOutlierRule(code, hasExistingRules || !hasStoredRules); - }); - - this.outlierReportRules = next; - this.outlierReportValidationErrors = Object.fromEntries( - Object.entries(this.outlierReportValidationErrors).filter(([code]) => allowedCodes.has(code)) - ); - } - - outlierReportRuleFor(variable: any): OutlierRule { - if (!this.outlierReportRules[variable.code]) { - this.outlierReportRules = { - ...this.outlierReportRules, - [variable.code]: createDefaultOutlierRule(variable.code, true), - }; - } - return this.outlierReportRules[variable.code]; - } - - outlierVariableLabel(variable: any): string { - return variable?.label ?? variable?.name ?? variable?.code ?? ''; - } - - onOutlierReportEnabledChange(variable: any, enabled: boolean): void { - const existing = this.outlierReportRuleFor(variable); - this.outlierReportRules = { - ...this.outlierReportRules, - [variable.code]: { ...existing, enabled }, - }; - this.outlierReportValidationErrors = { - ...this.outlierReportValidationErrors, - [variable.code]: '', - }; - this.persistOutlierReportConfig(); - } - - onOutlierReportStrategyChange(variable: any, strategy: OutlierStrategy): void { - const existing = this.outlierReportRuleFor(variable); - this.outlierReportRules = { - ...this.outlierReportRules, - [variable.code]: { - ...existing, - enabled: true, - strategy, - fold: defaultFoldForStrategy(strategy), - }, - }; - this.outlierReportValidationErrors = { - ...this.outlierReportValidationErrors, - [variable.code]: '', - }; - this.persistOutlierReportConfig(); - } - - onOutlierReportTailChange(variable: any, tail: OutlierTail): void { - const existing = this.outlierReportRuleFor(variable); - this.outlierReportRules = { - ...this.outlierReportRules, - [variable.code]: { ...existing, enabled: true, tail }, - }; - this.persistOutlierReportConfig(); - } - - onOutlierReportFoldChange(variable: any, rawValue: string | number): void { - const existing = this.outlierReportRuleFor(variable); - const value = String(rawValue ?? '').trim(); - const fold = value === '' ? null : Number(value); - this.outlierReportRules = { - ...this.outlierReportRules, - [variable.code]: { - ...existing, - enabled: true, - fold: typeof fold === 'number' && Number.isFinite(fold) ? fold : null, - }, - }; - const error = validateOutlierRule(this.outlierReportRules[variable.code]); - this.outlierReportValidationErrors = { - ...this.outlierReportValidationErrors, - [variable.code]: error ?? '', - }; - this.persistOutlierReportConfig(); - } - - outlierReportRuleError(variable: any): string { - const rule = this.outlierReportRuleFor(variable); - return this.outlierReportValidationErrors[variable.code] || validateOutlierRule(rule) || ''; - } - - private validateOutlierReportConfig(): boolean { - if (!this.isOutlierReportSelected()) return true; - - const variables = this.outlierReportVariables(); - if (!variables.length) { - this.errorMsg.set('Select at least one numerical variable or covariate for the outlier report.'); - return false; - } - - const errors: Record = {}; - variables.forEach((variable) => { - const rule = this.outlierReportRuleFor(variable); - const error = validateOutlierRule(rule); - if (error) errors[variable.code] = error; - }); - - this.outlierReportValidationErrors = errors; - if (Object.keys(errors).length > 0) { - this.errorMsg.set('Fix the outlier report configuration before running.'); - return false; - } - - const serialized = serializeOutlierRules( - this.outlierReportRules, - new Set(variables.map((variable) => variable.code)) - ); - if (!serialized) { - this.errorMsg.set('Enable at least one numerical variable for the outlier report.'); - return false; - } - - this.persistOutlierReportConfig(); - return true; - } - - - /** Transformation grid operates on the assigned outcome variables (y). */ - get selectedVariables() { - return this.experimentStudioService.algorithmY(); - } - - /** Assignable pool drives the role-assignment UI (shown without a selected algorithm). */ - get assignablePool() { - return this.experimentStudioService.algorithmAssignableVariables(); - } - - - readonly tooltipVisible = signal(false); - readonly tooltipPosition = signal({ x: 0, y: 0 }); - readonly tooltipData = signal(null); - - selectAlgorithm(algorithm: AlgorithmConfig) { - this.lastBuiltFormSchemaSignature = ''; - this.eventVarSelection.set(null); - // Use service method so it enriches configSchema and persists to sessionStorage - this.experimentStudioService.setAlgorithm(algorithm); - this.experimentStudioService.runResult.set(null); - this.experimentStudioService.runError.set(null); - this.errorMsg.set(null); - } - - onAlgorithmClick(algorithm: AlgorithmConfig) { - this.selectAlgorithm(algorithm); - // Parent bookkeeping only: the details and parameters bands render from - // selectedAlgorithm() below the row, so no substep navigation or page scroll. - this.setStudioSubstep('parameters'); - this.hideTooltip(); - } - - /** Scrolls a studio anchor; `scroll-margin-top` on the target clears sticky chrome. */ - private scrollStudioTargetIntoView(target: HTMLElement | null, focus = false): void { - if (!target) return; - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - if (focus) target.focus({ preventScroll: true }); - } - - onClickRunExp() { - this.errorMsg.set(null); - this.errorService.clearError(); - - if (this.selectedAlgorithmUnavailable()) { - this.errorMsg.set('This algorithm is unavailable for the current selection.'); - return; - } - - const unavailableVariables = this.unavailableRunVariablesReason(); - if (unavailableVariables) { - this.errorMsg.set(unavailableVariables); - return; - } - - const preprocessingMessage = this.preprocessingRunBlockMessage(); - if (preprocessingMessage) { - this.errorMsg.set(preprocessingMessage); - return; - } - - // algorithm does not run if form is invalid - if (this.configForm() && this.configForm().invalid) { - this.configForm().markAllAsTouched(); - console.warn('[AlgorithmPanel] Run blocked – configForm invalid'); - return; - } - - const algo = this.experimentStudioService.selectedAlgorithm(); - this.errorMsg.set(null); - this.errorService.clearError(); - this.experimentStudioService.runStatusText.set('Processing experiment...'); - this.experimentStudioService.hasRunStarted.set(true); - this.experimentStudioService.runError.set(null); - this.experimentStudioService.runResult.set(null); - this.experimentStudioService.runId.set(null); - this.experimentStudioService.setRunning(true); - - if (!algo) { - console.error('No algorithm selected in service.'); - const msg = 'Please choose an algorithm before running.'; - this.errorMsg.set(msg); - this.experimentStudioService.setRunning(false); - this.experimentStudioService.hasRunStarted.set(false); - return; - } - - if (!this.validateOutlierReportConfig()) { - this.experimentStudioService.setRunning(false); - return; - } - - const isCvOnly = this.experimentStudioService.isCrossValidationOnly(algo.name); - const baseAlgorithmName = isCvOnly - ? algo.name - : this.experimentStudioService.getCrossValidationBase(algo.name) ?? algo.name; - const cvVariant = - this.experimentStudioService.getCrossValidationVariant(baseAlgorithmName); - const shouldIncludeSplits = - this.crossValidationEnabled() || - this.experimentStudioService.isCrossValidationOnly(algo.name); - const useCrossValidation = shouldIncludeSplits && !!cvVariant; - const effectiveAlgorithmName = useCrossValidation - ? cvVariant - : baseAlgorithmName; - - const finalAlgorithmName = isCvOnly ? algo.name : effectiveAlgorithmName; - - this.experimentStudioService.lastUsedAlgorithm.set(finalAlgorithmName); - this.errorMsg.set(null); - - let configValues = this.normalizeFormValues(this.configForm().getRawValue(), this.visibleConfigSchema()); - configValues = this.mergeOutlierReportConfig(configValues); - if (!shouldIncludeSplits && configValues['n_splits'] !== undefined) { - delete configValues['n_splits']; - } - this.experimentStudioService.algorithmConfigurations.set({ - ...this.experimentStudioService.algorithmConfigurations(), - [baseAlgorithmName]: configValues, - ...(effectiveAlgorithmName !== baseAlgorithmName ? { [effectiveAlgorithmName]: configValues } : {}) - }); - - // Freeze the setup before the request leaves: the Execution step describes the run from - // this snapshot, and parameter edits after the run must not rewrite it. - this.experimentStudioService.captureRunSetup(finalAlgorithmName); - - const result$ = this.experimentStudioService.runSelectedAlgorithm( - baseAlgorithmName, - finalAlgorithmName - ); - if (!result$) { - const msg = 'Unable to start the run. Check your selections.'; - this.errorMsg.set(msg); - this.experimentStudioService.setRunning(false); - this.experimentStudioService.hasRunStarted.set(false); - return; - } - - result$.subscribe({ - next: (res) => { - const status = res?.status; - const payload = res?.result ?? {}; - if (status === 'error') { - const msg = - payload?.data || - payload?.message || - 'The server returned an error for this run.'; - this.experimentStudioService.runError.set(msg); - return; - } - - const schema = getOutputSchema(finalAlgorithmName ?? '') ?? []; - this.experimentStudioService.runResult.set({ - ...res?.result ?? { message: "No result returned" }, - }); - // Save persists this kept run, so the saved experiment is the result shown here. - this.experimentStudioService.runId.set(res?.uuid ?? null); - this.lastUsedAlgorithm = finalAlgorithmName; - this.experimentStudioService.lastRunSchema.set(schema); - }, - error: () => { - this.experimentStudioService.runError.set('Unable to run experiment. Please try again.'); - // An errored stream never completes, so the run state is released here too. - this.experimentStudioService.setRunning(false); - }, - complete: () => { - this.experimentStudioService.setRunning(false); - } - }); - - } - - toggleCrossValidation(event: Event) { - const target = event.target as HTMLInputElement | null; - const enabled = !!target?.checked; - const algorithm = this.selectedAlgorithm(); - if (!algorithm) return; - const baseName = this.experimentStudioService.getCrossValidationBase(algorithm.name) ?? algorithm.name; - const variant = this.experimentStudioService.getCrossValidationVariant(baseName); - if (!variant) return; - const configs = this.experimentStudioService.algorithmConfigurations(); - const currentFormValues = this.configForm()?.getRawValue?.() ?? {}; - const preservedConfig = { ...(configs[baseName] ?? {}), ...currentFormValues }; - - this.crossValidationSelections[baseName] = enabled; - - if (!enabled) { - if (preservedConfig['n_splits'] !== undefined) delete preservedConfig['n_splits']; - - if (this.configForm()?.contains('n_splits')) { - this.configForm().removeControl('n_splits'); - } - - const variantConfig = { ...(configs[variant] ?? {}) }; - if (variantConfig['n_splits'] !== undefined) delete variantConfig['n_splits']; - this.experimentStudioService.algorithmConfigurations.set({ - ...configs, - [baseName]: preservedConfig, - [variant]: variantConfig, - }); - } else { - this.experimentStudioService.algorithmConfigurations.set({ - ...configs, - [baseName]: preservedConfig, - [variant]: { ...(configs[variant] ?? {}), ...preservedConfig }, - }); - } - - this.crossValidationEnabled.set(enabled); - } - - private multiSelectValues(key: string): string[] { - const value = this.configForm().get(key)?.value; - if (Array.isArray(value)) return value.map((entry) => String(entry)); - if (typeof value === 'string' && value.trim() !== '') { - return value.split(',').map((entry) => entry.trim()).filter((entry) => entry !== ''); - } - return []; - } - - isMultiSelectOptionSelected(key: string, option: unknown): boolean { - const value = String(this.bindOptionValue(option)); - return this.multiSelectValues(key).includes(value); - } - - toggleMultiSelectOption(key: string, option: unknown): void { - const control = this.configForm().get(key); - if (!control) return; - const value = String(this.bindOptionValue(option)); - const current = this.multiSelectValues(key); - const next = current.includes(value) - ? current.filter((entry) => entry !== value) - : [...current, value]; - control.setValue(next); - control.markAsTouched(); - control.updateValueAndValidity(); - } - - isAlgorithmAvailable(algorithm: string): boolean { - return this.experimentStudioService.isAlgorithmAvailable(algorithm); - } - - availabilityDetails(algorithm: AlgorithmConfig | null = null): AlgorithmAvailabilityDetail[] { - const target = algorithm ?? this.tooltipData(); - if (!target) return []; - if (target.availability?.details?.length) return target.availability.details; - return this.experimentStudioService.getAlgorithmAvailability(target.name).details ?? []; - } - - availabilityRequirementText(detail: AlgorithmAvailabilityDetail): string { - const count = this.formatAvailabilityCount(detail); - const parts = [detail.label + ': ' + count + ', selected ' + detail.selectedCount]; - const types = this.formatRequirementTypes(detail.types); - if (types) { - parts.push('type: ' + types.join(', ')); - } - return parts.join(' • '); - } - - private formatAvailabilityCount(detail: AlgorithmAvailabilityDetail): string { - return formatInputCountRange(detail.minCount, detail.maxCount); - } - - private formatAvailabilityMessage(message: string): string { - return message.replace( - /((?:Outcome|Predictor) type must be one of )([^.]*)\./g, - (_match, prefix: string, types: string) => { - const displayTypes = this.formatRequirementTypes( - types.split(',').map((type) => type.trim()) - ); - return prefix + (displayTypes ?? []).join(', ') + '.'; - } - ); - } - - resolveRunRequirement(requirement: AlgorithmRunRequirement): void { - if (requirement.id === 'preprocessing') { - this.studioNavigation.goToPreprocessing(); - return; - } - // Availability issues are fixed on the setup board, which stays on screen above. - this.setStudioSubstep('setup'); - requestAnimationFrame(() => - this.scrollStudioTargetIntoView( - document.querySelector('[data-guide="guide-role-assignment"]'), - true - ) - ); - } - - private availabilityRequirementActionLabel(algorithmName: string): string { - const failingDetail = this.experimentStudioService - .getAlgorithmAvailability(algorithmName) - .details - .find((detail) => !detail.satisfied && detail.messages.length > 0); - - if (failingDetail?.role === 'x') { - return 'Assign predictor'; - } - - return 'Assign outcome'; - } - - showTooltip(algorithm: any, event: MouseEvent) { - event.stopPropagation(); - const item = (event.currentTarget as HTMLElement)?.closest('li') || (event.currentTarget as HTMLElement); - const rect = item.getBoundingClientRect(); - const container = item.closest('.experiment-container') as HTMLElement | null; - const containerRect = container?.getBoundingClientRect() ?? new DOMRect(0, 0, window.innerWidth, window.innerHeight); - - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const padding = 16; - const offset = 12; - - this.tooltipVisible.set(true); - this.tooltipData.set(algorithm); - - const initialPosition = this.getTooltipPosition(rect, containerRect, 320, 200, viewportWidth, viewportHeight, padding, offset); - - // Immediate initial placement - this.tooltipPosition.set(initialPosition); - - // Refinement after render - setTimeout(() => { - const el = document.querySelector('.tooltip') as HTMLElement | null; - if (!el) return; - - const w = el.offsetWidth || 320; - const h = el.offsetHeight || 200; - - this.tooltipPosition.set( - this.getTooltipPosition(rect, containerRect, w, h, viewportWidth, viewportHeight, padding, offset) - ); - }, 0); - } - - hideTooltip() { - this.tooltipVisible.set(false); - } - - private getTooltipPosition( - rect: DOMRect, - containerRect: DOMRect, - tooltipWidth: number, - tooltipHeight: number, - viewportWidth: number, - viewportHeight: number, - padding: number, - offset: number, - ): { x: number; y: number } { - const rightAlignedX = rect.right + offset; - const leftAlignedX = rect.left - tooltipWidth - offset; - const centeredY = rect.top + (rect.height - tooltipHeight) / 2; - - let x = rightAlignedX; - if (rightAlignedX + tooltipWidth > viewportWidth - padding) { - x = leftAlignedX >= padding - ? leftAlignedX - : viewportWidth - tooltipWidth - padding; - } - - let y = centeredY; - if (y + tooltipHeight > viewportHeight - padding) { - y = viewportHeight - tooltipHeight - padding; - } - if (y < padding) { - y = padding; - } - - return { - x: Math.max(padding, x - containerRect.left), - y: Math.max(padding, y - containerRect.top), - }; - } - - private normalizeRequirementDisplayType(type: unknown): string | null { - const normalized = String(type ?? '').trim().toLowerCase(); - if (!normalized) return null; - - switch (normalized) { - case VariableTypes.TEXT: - case 'polynominal': - case 'ordinal': - return VariableTypes.NOMINAL; - case VariableTypes.INTEGER: - return VariableTypes.INT; - default: - return normalized; - } - } - - private formatRequirementTypes(types: unknown): string[] | null { - if (!Array.isArray(types) || types.length === 0) { - return null; - } - - const normalized = types - .map((type) => this.normalizeRequirementDisplayType(type)) - .filter((type): type is string => !!type); - - const unique = Array.from(new Set(normalized)); - return unique.length ? unique : null; + setStudioSubstep(step: AlgorithmStudioSubstep): void { + this.substep.set(step); } - onExportResult(section: HTMLElement) { - const payload = this.resultExportPayload(); - if (!section || !payload) { - console.warn('No result or element to export'); - return; - } - this.pdfExport.exportExperimentPdf({ ...payload, chartContainer: section }); + /** The studio's run button. */ + onClickRunExp(): void { + this.run.runExperiment(); } - onExportResultZip() { - const payload = this.resultExportPayload(); - if (!payload) { - console.warn('No result to export'); - return; - } - this.csvExport.exportExperimentZip({ - ...payload, - tables: experimentTables(payload.algorithmKey, payload.result), - }).catch((error) => { - console.error('Result ZIP export failed:', error); - this.errorMsg.set('Failed to export the result tables.'); - }); + /** The Execution step's PDF export, with the result figures in `section`. */ + onExportResult(section: HTMLElement): void { + this.run.exportPdf(section); } - /** The run's setup and result, shared by the PDF report and the ZIP export. */ - private resultExportPayload() { - const result = this.experimentStudioService.runResult(); - if (!result) return null; - - const info = this.experimentInfo(); - const algoKey = - this.lastUsedAlgorithm || - this.experimentStudioService.lastUsedAlgorithm() || - this.experimentStudioService.selectedAlgorithm()?.name || - 'experiment'; - - const algoConfig = this.experimentStudioService.backendAlgorithms()[algoKey]; - const algoLabel = algoConfig?.label || algoKey; - - const currentUser = this.authService.currentUser; - const createdBy = - currentUser?.fullname || currentUser?.username || currentUser?.email || null; - - const filterLogic = this.experimentStudioService.filterLogic(); - const enumMaps = this.experimentStudioService.getCategoricalEnumMaps(); - const filterExpression = filterLogic - ? formatFilterExpression(filterLogic, { labelMap: this.labelMap(), enumMaps }) - : ''; - const model = this.experimentStudioService.selectedDataModel(); - - return { - filename: info.experimentName, - details: { - experimentName: info.experimentName, - createdBy, - createdAt: new Date(), - algorithm: algoLabel, - params: info.algorithmConfigs, - preprocessing: this.experimentStudioService - .getEffectivePreprocessingEntries(algoKey, this.labelMap(), enumMaps) - .map((entry) => `${entry.label}: ${entry.value}`) - .join('\n'), - domain: model ? [model.label || model.code, model.version].filter(Boolean).join(' ') : null, - datasets: this.datasetsWithLabels().map((d) => d.label), - variables: (info.variables ?? []).map((v: any) => v.label || v.name || v.code), - covariates: (info.covariates ?? []).map((c: any) => c.label || c.name || c.code), - filters: filterExpression - ? [filterExpression] - : (info.filters ?? []).map((f: any) => f.label || f.name || f.code), - transformations: null, - mipVersion: this.mipVersion, - }, - algorithmKey: algoKey, - result, - }; + /** The Execution step's ZIP export. */ + onExportResultZip(): void { + this.run.exportZip(); } } diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.models.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.models.ts new file mode 100644 index 00000000..81289348 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-panel.models.ts @@ -0,0 +1,92 @@ +import { AlgorithmConfig } from '../../../models/algorithm-definition.model'; + +/** Studio sub-steps of the Algorithm view: setup (roles + methods), then parameters (configure + run). */ +export type AlgorithmStudioSubstep = 'setup' | 'parameters'; + +type AlgorithmRunRequirementKind = 'availability' | 'preprocessing'; + +/** Something that blocks the run, with the action that fixes it. */ +export interface AlgorithmRunRequirement { + id: AlgorithmRunRequirementKind; + message: string; + actionLabel: string; +} + +/** One line of the "Requirements before running" list. */ +export interface AlgorithmNeedItem { + key: string; + met: boolean; + title: string; + messages: string[]; + action?: AlgorithmRunRequirement; +} + +/** A catalog group as the panel lists it. */ +export interface AlgorithmCategory { + name: string; + algorithms: AlgorithmConfig[]; +} + +/** An enumeration entry or a select option, as the backend and the data model send them. */ +export type ParameterOption = + | string + | number + | boolean + | { code?: unknown; value?: unknown; name?: unknown; label?: unknown }; + +/** + * A studio variable as the panel reads it: entries of `algorithmY`, `algorithmX`, + * `algorithmAssignableVariables` and `selectedFilters`. + */ +export interface StudioVariable { + code?: string; + label?: string; + name?: string; + type?: string; + enumerations?: ParameterOption[]; +} + +/** One algorithm parameter of `AlgorithmConfig.configSchema` (see `buildConfigSchema` in core/algorithm-mappers). */ +export interface AlgorithmParameterField { + key: string; + label?: string; + desc?: string; + description?: string; + type?: string; + types?: string[]; + required?: boolean | string; + default?: unknown; + options?: ParameterOption[]; + enumType?: string; + enumSource?: string[]; + min?: number; + max?: number; + advanced?: boolean; +} + +/** A parameter after enrichment: options resolved from the selected variables, label and desc filled in. */ +export interface EnrichedParameterField extends AlgorithmParameterField { + label: string; + desc: string; + required?: boolean; +} + +/** A `{ code, label }` choice offered by a select built from variables or enumerations. */ +export interface CodeLabelOption { + code: string; + label: string; +} + +/** A numerical variable the outlier report editor lists (always has a code). */ +export interface OutlierReportVariable extends StudioVariable { + code: string; +} + +/** The run an export describes. */ +export interface ExperimentRunInfo { + experimentName: string; + variables: StudioVariable[]; + covariates: StudioVariable[]; + filters: StudioVariable[]; + algorithmConfigs: Record; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.spec.ts new file mode 100644 index 00000000..fc4cbe35 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.spec.ts @@ -0,0 +1,119 @@ +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { AlgorithmAvailabilityDetail, AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { AlgorithmCatalogService } from './algorithm-catalog.service'; + +const base: AlgorithmConfig = { + name: 'anova_oneway', + label: 'One-way ANOVA', + description: '', + category: 'Group comparison', + requiredVariable: [], + covariate: [], + configSchema: [], + isDisabled: false, +}; + +const detail = (messages: string[]): AlgorithmAvailabilityDetail => ({ + role: 'y', + label: 'Outcome', + selectedCount: 0, + minCount: 1, + maxCount: 1, + required: true, + types: [], + stattypes: [], + messages, + satisfied: messages.length === 0, +}); + +describe('AlgorithmCatalogService', () => { + let studio: { + selectedAlgorithm: ReturnType>; + availableGroupedAlgorithms: ReturnType>>; + isAlgorithmAvailable: ReturnType; + getAlgorithmAvailability: ReturnType; + }; + let service: AlgorithmCatalogService; + + beforeEach(() => { + studio = { + selectedAlgorithm: signal(null), + availableGroupedAlgorithms: signal>({ + 'Group comparison': [base, { ...base, name: 'kruskal', label: 'Kruskal-Wallis' }], + Association: [{ ...base, name: 'correlation', label: 'Correlation', isDisabled: true }], + }), + isAlgorithmAvailable: vi.fn().mockReturnValue(true), + getAlgorithmAvailability: vi.fn().mockReturnValue({ available: true, summary: '', details: [] }), + }; + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: ExperimentStudioService, useValue: studio }, + AlgorithmCatalogService, + ], + }); + service = TestBed.inject(AlgorithmCatalogService); + }); + + it('counts and filters methods by the Runnable/All switch and search text', () => { + expect(service.algoCounts()).toEqual({ all: 3, active: 2 }); + expect(service.filteredAlgorithmCategories().map((category) => category.name)).toEqual(['Group comparison']); + + service.toggleActiveOnly(); + expect(service.filteredAlgorithmCategories().map((category) => category.name)).toEqual(['Group comparison', 'Association']); + + service.algoSearchQuery.set('kruskal'); + expect(service.searchActive()).toBe(true); + expect(service.filteredAlgorithmCategories().flatMap((category) => category.algorithms.map((a) => a.name))).toEqual(['kruskal']); + + service.clearSearch(); + expect(service.searchActive()).toBe(false); + }); + + it('keeps a group the user toggled as they left it when the selection moves', () => { + service.toggleActiveOnly(); + expect(service.isCategoryOpen('Group comparison')).toBe(true); + expect(service.isCategoryOpen('Association')).toBe(false); + + service.toggleCategory('Association'); + service.toggleCategory('Group comparison'); + expect(service.isCategoryOpen('Association')).toBe(true); + expect(service.isCategoryOpen('Group comparison')).toBe(false); + + // Selecting a method opens its group, without closing the one the user opened. + studio.selectedAlgorithm.set(base); + expect(service.isCategoryOpen('Group comparison')).toBe(true); + expect(service.isCategoryOpen('Association')).toBe(true); + }); + + it('words why a method is unavailable from its summary, else its first failed check', () => { + studio.getAlgorithmAvailability.mockReturnValue({ available: false, summary: 'Outcome type must be one of text.', details: [] }); + expect(service.algorithmUnavailableReason(base)).toBe('Outcome type must be one of nominal.'); + + studio.getAlgorithmAvailability.mockReturnValue({ available: false, summary: '', details: [] }); + const withDetails = { ...base, availability: { available: false, summary: '', details: [detail([]), detail(['Outcome needs at least 1, selected 0.'])] } }; + expect(service.algorithmUnavailableReason(withDetails)).toBe('Outcome needs at least 1, selected 0.'); + + expect(service.algorithmUnavailableReason(base)).toBe('Unavailable'); + }); + + it('reads availability checks from the method, else from the studio', () => { + const own = [detail(['own'])]; + const studioDetails = [detail(['studio'])]; + studio.getAlgorithmAvailability.mockReturnValue({ available: false, summary: '', details: studioDetails }); + + expect(service.availabilityDetails(null)).toEqual([]); + expect(service.availabilityDetails({ ...base, availability: { available: false, summary: '', details: own } })).toBe(own); + expect(service.availabilityDetails(base)).toBe(studioDetails); + }); + + it('marks the selected method unavailable from the studio availability', () => { + expect(service.selectedAlgorithmUnavailable()).toBe(false); + studio.selectedAlgorithm.set(base); + studio.isAlgorithmAvailable.mockReturnValue(false); + studio.selectedAlgorithm.set({ ...base }); + expect(service.selectedAlgorithmUnavailable()).toBe(true); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts new file mode 100644 index 00000000..a7c6ca71 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog.service.ts @@ -0,0 +1,100 @@ +import { Injectable, computed, inject, linkedSignal, signal, untracked } from '@angular/core'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { AlgorithmAvailabilityDetail, AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { formatAvailabilityMessage } from '../utils/algorithm-availability-text.utils'; +import { countAlgorithms, filterAlgorithmCategories, nextCollapsedCategories } from '../utils/algorithm-catalog.utils'; +import { AlgorithmCategory } from '../algorithm-panel.models'; + +/** + * The method catalog: search text, the Runnable/All filter, which groups are open, and why + * a method is unavailable. Provided by the algorithm panel. + */ +@Injectable() +export class AlgorithmCatalogService { + private readonly studio = inject(ExperimentStudioService); + + // The catalog defaults to runnable methods only: each group lists just the available + // algorithms. Switching to All re-lists unavailable methods with their reason. + readonly showOnlyActive = signal(true); + /** Instant text filter over label, name, description, documentation, category, and flags. */ + readonly algoSearchQuery = signal(''); + readonly searchActive = computed(() => this.algoSearchQuery().trim().length > 0); + + readonly filteredAlgorithmCategories = computed(() => filterAlgorithmCategories( + this.studio.availableGroupedAlgorithms(), + this.showOnlyActive(), + this.algoSearchQuery(), + )); + readonly hasAnyVisibleAlgorithms = computed(() => + this.filteredAlgorithmCategories().some((category) => category.algorithms?.length > 0)); + readonly algoCounts = computed(() => countAlgorithms(this.studio.availableGroupedAlgorithms())); + + /** Groups the user opened or closed themselves; these are never changed automatically. */ + private readonly userToggledCategories = signal>(new Set()); + + /** + * Collapsed catalog groups. A group missing from this set is open, so groups coming back + * after a filter switch keep the state the user left them in. Recomputed from the listed + * groups and the selection (see nextCollapsedCategories); a toggle sets it directly. + */ + private readonly collapsedCategories = linkedSignal< + { categories: AlgorithmCategory[]; selectedName: string | null }, + ReadonlySet + >({ + source: () => ({ + categories: this.filteredAlgorithmCategories(), + selectedName: this.studio.selectedAlgorithm()?.name ?? null, + }), + computation: (source, previous) => nextCollapsedCategories( + source.categories, + source.selectedName, + previous?.source.selectedName !== source.selectedName, + previous?.value ?? new Set(), + untracked(this.userToggledCategories), + ), + }); + + /** The selected method cannot run with the current roles and datasets. */ + readonly selectedAlgorithmUnavailable = computed(() => { + const algorithm = this.studio.selectedAlgorithm(); + return !!algorithm && !this.studio.isAlgorithmAvailable(algorithm.name); + }); + + toggleActiveOnly(): void { + this.showOnlyActive.update((value) => !value); + } + + clearSearch(): void { + this.algoSearchQuery.set(''); + } + + isCategoryOpen(categoryName: string): boolean { + return !this.collapsedCategories().has(categoryName); + } + + toggleCategory(categoryName: string): void { + this.userToggledCategories.update((toggled) => new Set(toggled).add(categoryName)); + const next = new Set(this.collapsedCategories()); + if (!next.delete(categoryName)) next.add(categoryName); + this.collapsedCategories.set(next); + } + + /** The role checks behind a method's availability: its own, else the studio's. */ + availabilityDetails(algorithm: AlgorithmConfig | null): AlgorithmAvailabilityDetail[] { + if (!algorithm) return []; + if (algorithm.availability?.details?.length) return algorithm.availability.details; + return this.studio.getAlgorithmAvailability(algorithm.name).details ?? []; + } + + /** The one-line reason on an unavailable method's tile. */ + algorithmUnavailableReason(algorithm: AlgorithmConfig): string { + const availability = this.studio.getAlgorithmAvailability(algorithm.name); + const summary = availability?.summary || algorithm.availability?.summary || ''; + if (summary) return formatAvailabilityMessage(summary); + const details = availability?.details?.length + ? availability.details + : (algorithm.availability?.details ?? []); + const first = details.find((detail) => detail.messages.length > 0); + return first ? formatAvailabilityMessage(first.messages[0]) : 'Unavailable'; + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.css new file mode 100644 index 00000000..76ea82c8 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.css @@ -0,0 +1,352 @@ +@import '../../styles/sr-only.shared.css'; +@import '../../styles/step-kicker.shared.css'; + +/* LEFT OF THE BOTTOM ROW: ALGORITHM CATALOG */ +/* The catalog title mirrors the pool's pane head so the two panes read as peers. */ +.catalog-title { + margin: 0; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--primary-color); +} + +.algorithm-match-empty { + margin: 0; + padding: 14px 12px; + font-size: 0.78rem; + color: var(--text-muted); + line-height: 1.45; + border: 1px dashed rgba(148, 163, 184, 0.4); + border-radius: var(--radius-md, 8px); +} + +/* Search */ +.catalog-search { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +/* The clear button overlays the field, so the field — not the row — is its containing + block; the filter chip sits beside the field instead of under it. */ +.catalog-search-field { + position: relative; + display: block; + flex: 1 1 auto; + /* Below this the chip drops to its own line rather than crushing the field: the + row wraps on this minimum plus the chip, so the field never shrinks under a + readable width when the catalog is narrow. */ + min-width: 8rem; +} + +.catalog-search-input { + display: block; + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 8px 34px 8px 12px; + border-radius: var(--radius-md, 8px); + border: 1px solid rgba(148, 163, 184, 0.34); + background: var(--studio-card-bg); + color: var(--text-main); + font: inherit; + font-size: 0.85rem; + line-height: 1.4; +} + +.catalog-search-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(127, 156, 232, 0.16); +} + +.catalog-search-input::placeholder { + color: #94a3b8; +} + +.catalog-search-clear { + position: absolute; + right: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: 0; + border-radius: var(--radius-sm, 4px); + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 1rem; + line-height: 1; + cursor: pointer; +} + +.catalog-search-clear:hover, +.catalog-search-clear:focus-visible { + background: color-mix(in srgb, var(--primary-color) 8%, white); + color: var(--text-main); +} + +/* One status chip (All / Runnable) sits on the search row — see .pill-toggle below. */ +/* Method list. Category kicker: chevron first, then the group name (per the mock). */ +.algorithm-match-rows { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding-right: 2px; +} + +.algorithm-match-row { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Category kicker doubles as the disclosure control for its tiles. */ +.algorithm-match-cat { + appearance: none; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + margin: 0; + padding: 2px 0; + border: 0; + background: none; + font-family: inherit; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.01em; + color: var(--primary-color); + line-height: 1.3; + text-align: left; + cursor: pointer; +} + +.algorithm-match-cat-name { + min-width: 0; +} + +.algorithm-match-cat-meta { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + order: -1; /* chevron sits before the label */ + color: var(--primary-color); +} + +/* Collapsed points right (›), expanded points down (⌄). */ +.algorithm-match-cat-meta::after { + content: ""; + width: 0.4rem; + height: 0.4rem; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.2s ease; +} + +.algorithm-match-cat[aria-expanded="true"] .algorithm-match-cat-meta::after { + transform: rotate(45deg); +} + +.algorithm-match-cat:hover .algorithm-match-cat-meta, +.algorithm-match-cat:focus-visible .algorithm-match-cat-meta { + color: var(--primary-color); +} + +.algorithm-match-tiles { + display: flex; + flex-direction: column; + gap: 6px; +} + +/* Algorithm row: plain label with trailing role chips; no selected highlight — + the chosen method is named in the details and parameters bands below. */ +.algo-tile { + appearance: none; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 2px; + width: 100%; + max-width: 100%; + border-radius: var(--radius-md, 8px); + border: 1px solid transparent; + background: #ffffff; + color: var(--text-main); + font: inherit; + font-size: 0.8rem; + font-weight: 500; + line-height: 1.45; + text-align: left; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.algo-tile:hover:not(.disabled-algo), +.algo-tile:focus-visible:not(.disabled-algo) { + border-color: rgba(43, 51, 233, 0.22); + background: color-mix(in srgb, var(--primary-light) 10%, white); +} + +/* Selected method: accent bar, primary border, and a subtle tint so the tile the + configuration column on the right refers to is unmistakable. */ +.algo-tile.selected:not(.disabled-algo) { + border-color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 8%, white); + box-shadow: inset 3px 0 0 var(--primary-color); +} + +/* The chevron points from the chosen tile into the configuration column, so the eye + lands on the details without scanning. Hidden until the tile is the selection. */ +.algo-tile-cue { + display: none; + flex: 0 0 auto; + width: 6px; + height: 6px; + margin-left: auto; + border-top: 2px solid var(--primary-color); + border-right: 2px solid var(--primary-color); + transform: rotate(45deg); +} + +.algo-tile.selected:not(.disabled-algo) .algo-tile-cue { + display: block; +} + +.algo-tile.selected:not(.disabled-algo) .algo-label { + color: var(--primary-color); + font-weight: 700; +} + +.algo-tile-top { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; +} + +.algo-tile .algo-label { + min-width: 0; + color: var(--text-main); + font-weight: 500; + overflow-wrap: anywhere; +} + +/* Required-input chips: amber y (outcome), lavender x (predictor) */ +.algo-why { + display: block; + font-weight: 500; + line-height: 1.4; +} + +.algo-tile.disabled-algo { + cursor: pointer; + background: color-mix(in srgb, var(--bg-neutral, #f8fafc) 55%, white); + border-color: transparent; + box-shadow: none; + color: var(--text-muted); +} + +.algo-tile.disabled-algo .algo-label { + color: var(--text-muted); +} + +.algo-tile.disabled-algo:hover, +.algo-tile.disabled-algo:focus-visible { + background: color-mix(in srgb, var(--text-muted) 8%, white); +} + +/* A read-only preview selection of an unavailable method: same accent bar, muted. */ +.algo-tile.disabled-algo.selected { + border-color: rgba(100, 116, 139, 0.55); + background: color-mix(in srgb, var(--text-muted) 8%, white); + box-shadow: inset 3px 0 0 rgba(100, 116, 139, 0.55); +} + +@media (max-width: 640px) { + .algo-why { + max-width: 100%; + } +} + +@media (prefers-reduced-motion: reduce) { + .algo-tile, + .algorithm-match-cat-meta::after { + transition: none; + } +} + +/* ========================================================================= + Algorithm panel review pass: step kickers, compact role band, catalog, + needs checklist, parameter layout, advanced grouping, option switches, + and the readiness bar. + ========================================================================= */ +.algorithm-step-kicker { + padding: 10px 14px 0; +} + +.catalog-mode-tabs { + flex: 0 0 auto; +} + +.catalog-mode-tabs button { + gap: 4px; + min-height: 26px; + padding: 0 10px; + font-size: 0.74rem; +} + +.catalog-title.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} + +.algorithm-match-cat-count { + margin-left: auto; + padding-left: 10px; + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 600; +} + +.algo-tile { + min-height: 36px; + padding: 8px 10px; +} + +.algo-tile-top::before { + content: ''; + flex: 0 0 auto; + width: 10px; + height: 10px; + border-radius: 50%; + box-shadow: inset 0 0 0 1.5px rgba(71, 85, 105, 0.45); +} + +.algo-tile.selected .algo-tile-top::before { + box-shadow: inset 0 0 0 3.5px var(--primary-color); +} + +.algo-tile.disabled-algo .algo-tile-top::before { + box-shadow: inset 0 0 0 1.5px rgba(71, 85, 105, 0.25); +} + +.algo-why { + font-size: 0.74rem; + color: #78350f; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.html new file mode 100644 index 00000000..8332508b --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.html @@ -0,0 +1,83 @@ +
2 Method
+

Algorithm catalog

+ + + +@if (catalog.hasAnyVisibleAlgorithms()) { +
+ @for (category of catalog.filteredAlgorithmCategories(); track category.name) { +
+ + @if (catalog.isCategoryOpen(category.name) || catalog.searchActive()) { +
+ @for (algorithm of category.algorithms; track algorithm.name) { + + } +
+ } +
+ } +
+} @else if (catalog.searchActive()) { +

No methods match “{{ catalog.algoSearchQuery() }}”.

+} @else if (catalog.showOnlyActive()) { +

No runnable methods for this assignment. Switch to All to see why.

+} @else { +

No methods in the catalog.

+} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts new file mode 100644 index 00000000..0a171d8c --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-catalog/algorithm-catalog.component.ts @@ -0,0 +1,38 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ExperimentStudioService } from '../../../../../services/experiment-studio.service'; +import { AlgorithmConfig } from '../../../../../models/algorithm-definition.model'; +import { runnableCount } from '../../utils/algorithm-catalog.utils'; +import { AlgorithmRunService } from '../../experiment-result/algorithm-run.service'; +import { AlgorithmStudioSubstepService } from '../../algorithm-studio-substep.service'; +import { AlgorithmCatalogService } from '../algorithm-catalog.service'; +import { AlgorithmConfigFormService } from '../algorithm-config-form.service'; + +/** The method catalog: search, the Runnable/All switch, and the method groups to pick from. */ +@Component({ + selector: 'aside[app-algorithm-catalog]', + templateUrl: './algorithm-catalog.component.html', + styleUrl: './algorithm-catalog.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlgorithmCatalogComponent { + private readonly studio = inject(ExperimentStudioService); + private readonly configForm = inject(AlgorithmConfigFormService); + private readonly run = inject(AlgorithmRunService); + private readonly substep = inject(AlgorithmStudioSubstepService); + protected readonly catalog = inject(AlgorithmCatalogService); + protected readonly selectedAlgorithm = this.studio.selectedAlgorithm; + protected readonly categoryRunnableCount = runnableCount; + + onSearchInput(event: Event): void { + this.catalog.algoSearchQuery.set((event.target as HTMLInputElement)?.value ?? ''); + } + + /** Selects the method; its details and parameters render beside the catalog, so nothing scrolls. */ + onAlgorithmClick(algorithm: AlgorithmConfig): void { + this.configForm.resetForNewAlgorithm(); + // Use service method so it enriches configSchema and persists to sessionStorage + this.studio.setAlgorithm(algorithm); + this.run.clearRunState(); + this.substep.set('parameters'); + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.css new file mode 100644 index 00000000..59398faa --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.css @@ -0,0 +1,124 @@ +@import '../../styles/form-controls.shared.css'; + +/* Label + "Required" */ +:host(.config-field) > label { + font-weight: 700; + font-size: 0.72rem; + color: var(--text-muted); + margin-bottom: 5px; + display: flex; + align-items: center; + gap: 8px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.config-field-desc { + margin: 0 0 8px; + font-size: 0.8rem; + line-height: 1.45; + color: #64748b; +} + +.config-field-hint { + margin: 0 0 8px; + font-size: 0.78rem; + line-height: 1.4; + color: var(--text-muted); +} + +:host(.config-field) > label { + grid-column: 1; + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding-top: 8px; + font-size: 0.84rem; + font-weight: 600; + color: var(--text-main); + text-transform: none; + letter-spacing: 0; +} + +.config-field-desc, +.config-field-hint { + grid-column: 1; + margin: 0; + font-size: 0.76rem; +} + +:host(.config-field) > .config-input, +:host(.config-field) > .config-select, +:host(.config-field) > .checkbox-wrapper, +:host(.config-field) > .chip-picker, +:host(.config-field) > .validation-message { + grid-column: 2; + grid-row: 1 / span 3; +} + +.optional-tag { + margin-left: 4px; + font-size: 0.66rem; + font-weight: 600; + color: var(--text-muted); +} + +.chip-picker { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} + +.chip-picker-option { + min-height: 28px; + padding: 0 10px; + border: 1px solid rgba(148, 163, 184, 0.34); + border-radius: 999px; + background: #fff; + color: var(--text-main); + font: inherit; + font-size: 0.76rem; + cursor: pointer; +} + +.chip-picker-option.active { + border-color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 8%, white); + color: var(--primary-color); + font-weight: 600; +} + +/* Muted validation: invalid keeps a danger edge; valid is neutral. Kept in this sheet rather + than in the form-controls partial: it must come after the rules above to win their ties. */ +.config-input.ng-valid.ng-touched, +.config-select.ng-valid.ng-touched { + border: 1px solid rgba(148, 163, 184, 0.34); + background: #fff; + background-image: none; + box-shadow: none; + padding-right: 12px; +} + +.config-input.ng-invalid.ng-touched, +.config-select.ng-invalid.ng-touched { + border: 1px solid var(--danger); + background-color: #fff; + background-image: none; + box-shadow: none; + padding-right: 12px; +} + +.config-input.ng-invalid.ng-touched:focus, +.config-select.ng-invalid.ng-touched:focus { + border-color: var(--danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 16%, transparent); +} + +@media (max-width: 900px) { + :host(.config-field) > * { + grid-column: 1 !important; + grid-row: auto !important; + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.html new file mode 100644 index 00000000..807991a0 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.html @@ -0,0 +1,93 @@ +@let field = this.field(); +@let control = form.configForm().get(field.key); + + +@if (field.desc) { +

{{ field.desc }}

+} + +@if (field.enumType === 'input_var_names' && field.type === 'select' && (!field.options || !field.options.length)) { +

+ @if (field.enumSource?.includes('x')) { + Select covariates first. This parameter must be one of the variables in x. + } @else if (field.enumSource?.includes('y')) { + Select a variable first. This parameter must be one of the variables in y. + } @else { + Select the required variables before choosing this parameter. + } +

+} + +@if (field.key === 'positive_class' && !form.selectedEventVarCode()) { +

Select an event indicator variable first.

+} + +@if (field.key === 'positive_class' && form.selectedEventVarCode() && form.positiveClassOptions().length) { +

+ Select the event level mapped to 1; all other levels are mapped to 0. +

+} + +@if (field.key === 'positive_class' && form.selectedEventVarCode() && !form.positiveClassOptions().length) { +

+ This event variable has no catalog categories. Enter the value treated as event=1 (for example 0 or 1). +

+} + +@if (field.type === 'number') { + + +} @else if (field.type === 'select') { + +} @else if (field.type === 'multi-select') { +
+ @for (opt of field.options; track opt) { + + } +
+} @else if (field.type === 'checkbox') { + +} @else { + +} + +@if (control?.invalid && control?.touched) { +
+ @if (control?.hasError('required')) { + This field is required. + } @if (control?.hasError('min')) { + Value too small. + } @if (control?.hasError('max')) { + Value too large. + } @if (control?.hasError('pattern')) { + Invalid value. + } @if (control?.hasError('invalidOption')) { + Select a value from the list. + } +
+} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.ts new file mode 100644 index 00000000..d46ce541 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-field/algorithm-config-field.component.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { ControlContainer, FormGroupDirective, ReactiveFormsModule } from '@angular/forms'; +import { allowsDecimal, optionLabel } from '../../utils/algorithm-parameter-form.utils'; +import { EnrichedParameterField } from '../../algorithm-panel.models'; +import { AlgorithmConfigFormService } from '../algorithm-config-form.service'; + +/** + * One parameter of the selected method: its label, hints, the control for its type, and its + * validation messages. Attached to the `.config-field` element, which carries the form group. + */ +@Component({ + selector: 'div[app-algorithm-config-field]', + imports: [ReactiveFormsModule], + // The form group sits on the host element; this lets formControlName in the view find it. + viewProviders: [{ provide: ControlContainer, useExisting: FormGroupDirective }], + templateUrl: './algorithm-config-field.component.html', + styleUrl: './algorithm-config-field.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlgorithmConfigFieldComponent { + readonly field = input.required(); + protected readonly form = inject(AlgorithmConfigFormService); + protected readonly allowsDecimal = allowsDecimal; + protected readonly optionLabel = optionLabel; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.spec.ts new file mode 100644 index 00000000..1ebcf531 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.spec.ts @@ -0,0 +1,102 @@ +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { StudioVariable } from '../algorithm-panel.models'; +import { AlgorithmConfigFormService } from './algorithm-config-form.service'; + +const algorithm: AlgorithmConfig = { + name: 'flat_config_algorithm', + label: 'Flat Config Algorithm', + description: '', + category: 'Test', + requiredVariable: [], + covariate: [], + configSchema: [ + { key: 'first', label: 'First', type: 'number', default: 1 }, + { key: 'second', label: 'Second', type: 'number', default: 2 }, + { key: 'third', label: 'Third', type: 'number', default: 3 }, + { key: 'fourth', label: 'Fourth', type: 'number', default: 4 }, + ], + isDisabled: false, +}; + +describe('AlgorithmConfigFormService', () => { + let studio: ReturnType; + let service: AlgorithmConfigFormService; + + function createStudio() { + return { + selectedAlgorithm: signal(algorithm), + algorithmY: signal([]), + algorithmX: signal([]), + algorithmAssignableVariables: signal([]), + algorithmConfigurations: signal>>({}), + backendAlgorithms: signal>({}), + isCrossValidationOnly: vi.fn().mockReturnValue(false), + getCrossValidationBase: vi.fn().mockReturnValue(null), + getCrossValidationVariant: vi.fn().mockReturnValue(null), + isCrossValidationAlgorithm: vi.fn().mockReturnValue(false), + }; + } + + beforeEach(() => { + studio = createStudio(); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: ExperimentStudioService, useValue: studio }, + AlgorithmConfigFormService, + ], + }); + service = TestBed.inject(AlgorithmConfigFormService); + TestBed.tick(); + }); + + it('keeps fields after the old advanced cutoff in the persisted form config', () => { + service.configForm().get('fourth')?.setValue('42'); + + expect(studio.algorithmConfigurations()['flat_config_algorithm']['fourth']).toBe(42); + }); + + it('builds the form from the stored config of the selected method', () => { + studio.algorithmConfigurations.set({ flat_config_algorithm: { first: 7 } }); + service.resetForNewAlgorithm(); + studio.selectedAlgorithm.set({ ...algorithm }); + TestBed.tick(); + + expect(service.configForm().get('first')?.value).toBe(7); + expect(service.configForm().get('second')?.value).toBe(2); + }); + + it('carries the parameters over to the cross-validation variant and back', () => { + studio.getCrossValidationVariant.mockImplementation((name: string) => name === 'flat_config_algorithm' ? 'flat_config_algorithm_cv' : null); + studio.backendAlgorithms.set({ flat_config_algorithm: algorithm }); + studio.algorithmConfigurations.set({ flat_config_algorithm_cv: { first: 1, n_splits: 5 } }); + TestBed.tick(); + + service.setCrossValidation(true); + expect(service.crossValidationEnabled()).toBe(true); + expect(studio.algorithmConfigurations()['flat_config_algorithm_cv']).toEqual( + expect.objectContaining({ first: 1, fourth: 4, n_splits: 5 })); + + service.setCrossValidation(false); + expect(service.crossValidationEnabled()).toBe(false); + expect(studio.algorithmConfigurations()['flat_config_algorithm_cv']).not.toHaveProperty('n_splits'); + }); + + it('reads an outlier_report variable without a rule as an enabled default', () => { + const rule = service.outlierReportRuleFor({ code: 'age', label: 'Age', type: 'real' }); + + expect(rule.enabled).toBe(true); + expect(service.outlierReportRuleError({ code: 'age' })).toBe(''); + }); + + it('clears the form when nothing is selected', () => { + studio.selectedAlgorithm.set(null); + TestBed.tick(); + + expect(Object.keys(service.configForm().controls)).toEqual([]); + expect(service.invalid()).toBe(false); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.ts new file mode 100644 index 00000000..35000dc2 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-config-form.service.ts @@ -0,0 +1,466 @@ +import { Injectable, computed, effect, inject, signal, untracked } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { AlgorithmNames } from '../../../../core/constants/algorithm.constants'; +import { optionBindingValue } from '../../../../core/algorithm-parameter.utils'; +import { + createDefaultOutlierRule, + defaultFoldForStrategy, + hydrateOutlierRules, + isOutlierEligibleVariable, + OutlierRule, + OutlierStrategy, + OutlierTail, + serializeOutlierRules, + validateOutlierRule, +} from '../../../../core/outlier-rules'; +import { + buildParameterForm, + enrichParameterSchema, + enumOptionsForVariableCode, + isAdvancedField, + multiSelectValues, + nonBlankString, + normalizeParameterValues, + parameterFieldLabel, + parameterFormSignature, +} from '../utils/algorithm-parameter-form.utils'; +import { + AlgorithmParameterField, + EnrichedParameterField, + OutlierReportVariable, + ParameterOption, + StudioVariable, +} from '../algorithm-panel.models'; + +/** outlier_report keys its rules by variable; the editor, not the form, edits them. */ +const OUTLIER_RULE_KEYS = ['strategies', 'tails', 'folds']; + +/** + * The selected method's parameter form: the schema enriched with the assigned roles, the + * reactive form built from it and persisted on every edit, cross-validation, the + * positive_class options for the chosen event variable, and the outlier_report rules. + * Provided by the algorithm panel. + */ +@Injectable() +export class AlgorithmConfigFormService { + private readonly studio = inject(ExperimentStudioService); + private readonly selectedAlgorithm = this.studio.selectedAlgorithm; + + private readonly form = signal(new FormGroup({})); + /** The selected method's parameter form; replaced when the schema it was built from changes. */ + readonly configForm = this.form.asReadonly(); + private readonly formInvalid = signal(false); + readonly invalid = this.formInvalid.asReadonly(); + /** Signature of the built form; cleared so that picking a method always rebuilds it. */ + private readonly builtFormSignature = signal(''); + + private readonly crossValidation = signal(false); + readonly crossValidationEnabled = this.crossValidation.asReadonly(); + /** Cross-validation on/off the user chose per base method; a memory, not reactive state. */ + private readonly crossValidationChoices = new Map(); + + readonly isCrossValidationOnly = computed(() => { + const algorithm = this.selectedAlgorithm(); + return !!algorithm && this.studio.isCrossValidationOnly(algorithm.name); + }); + + readonly canToggleCrossValidation = computed(() => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) return false; + if (this.studio.isCrossValidationOnly(algorithm.name)) return false; + const baseName = this.studio.getCrossValidationBase(algorithm.name) ?? algorithm.name; + return !!this.studio.getCrossValidationVariant(baseName) && !!this.studio.backendAlgorithms()[baseName]; + }); + + readonly isOutlierReportSelected = computed( + () => this.selectedAlgorithm()?.name === AlgorithmNames.OUTLIER_REPORT + ); + + /** Live event_var from the config form; avoids reading FormGroup inside other computeds. */ + private readonly eventVarSelection = signal(null); + + readonly selectedEventVarCode = computed(() => { + const algorithm = this.selectedAlgorithm(); + return algorithm ? this.resolveSelectedEventVarCode(algorithm.name) : null; + }); + + readonly positiveClassOptions = computed(() => this.enumOptionsForVariableCode(this.selectedEventVarCode())); + + readonly enrichedConfigSchema = computed(() => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) return []; + return enrichParameterSchema(algorithm, { + y: this.studio.algorithmY(), + x: this.studio.algorithmX(), + eventVarCode: this.selectedEventVarCode(), + withCrossValidationFolds: this.crossValidation() && this.canToggleCrossValidation(), + }); + }); + + /** The fields the form shows; outlier_report's rule dictionaries belong to its editor. */ + readonly visibleConfigSchema = computed(() => { + const schema = this.enrichedConfigSchema(); + if (!this.isOutlierReportSelected()) return schema; + return schema.filter((field) => field.type !== 'dict' && !OUTLIER_RULE_KEYS.includes(String(field.key))); + }); + + readonly primaryConfigSchema = computed(() => + this.visibleConfigSchema().filter((field) => + !this.isCrossValidationField(field) && !isAdvancedField(field) && this.form().get(field.key)) + ); + + readonly advancedConfigSchema = computed(() => + this.visibleConfigSchema().filter((field) => + !this.isCrossValidationField(field) && isAdvancedField(field) && this.form().get(field.key)) + ); + + readonly crossValidationConfigSchema = computed(() => + this.crossValidation() + ? this.visibleConfigSchema().filter((field) => this.isCrossValidationField(field) && this.form().get(field.key)) + : [] + ); + + readonly hasAlgorithmParameterControls = computed(() => ( + this.visibleConfigSchema().length > 0 || + this.isOutlierReportSelected() || + this.canToggleCrossValidation() || + this.isCrossValidationOnly() + )); + + /** Numerical variables of the assignable pool, once each: the outlier_report rows. */ + readonly outlierReportVariables = computed(() => { + const unique = new Map(); + this.studio.algorithmAssignableVariables().forEach((variable: StudioVariable) => { + if (hasCode(variable)) unique.set(String(variable.code), variable); + }); + return Array.from(unique.values()).filter((variable) => isOutlierEligibleVariable(variable)); + }); + + /** Per-variable outlier_report rules, keyed by variable code. */ + private readonly outlierReportRules = signal>({}); + /** Fold errors shown under each outlier_report rule, keyed by variable code. */ + private readonly outlierReportValidationErrors = signal>({}); + + constructor() { + effect(() => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) { + this.crossValidation.set(false); + return; + } + if (this.studio.isCrossValidationOnly(algorithm.name)) { + this.crossValidation.set(true); + return; + } + const baseName = this.studio.getCrossValidationBase(algorithm.name) ?? algorithm.name; + if (!this.studio.getCrossValidationVariant(baseName)) { + this.crossValidation.set(false); + return; + } + const stored = this.crossValidationChoices.get(baseName); + this.crossValidation.set(stored ?? this.studio.isCrossValidationAlgorithm(algorithm.name)); + }); + + effect(() => { + const algorithm = this.selectedAlgorithm(); + const variables = this.outlierReportVariables(); + if (!algorithm || algorithm.name !== AlgorithmNames.OUTLIER_REPORT) { + this.outlierReportValidationErrors.set({}); + return; + } + const stored = untracked(() => this.studio.algorithmConfigurations()[algorithm.name] ?? {}); + this.reconcileOutlierReportRules(variables, stored); + }); + + effect((onCleanup) => { + const algorithm = this.selectedAlgorithm(); + const schema = this.visibleConfigSchema(); + + if (!algorithm) { + this.builtFormSignature.set(''); + this.eventVarSelection.set(null); + this.form.set(new FormGroup({})); + this.formInvalid.set(false); + return; + } + + const schemaSignature = parameterFormSignature(algorithm.name, this.crossValidation(), schema); + if (schemaSignature === untracked(this.builtFormSignature)) { + return; + } + this.builtFormSignature.set(schemaSignature); + + const stored = untracked(() => this.studio.algorithmConfigurations())?.[algorithm.name] || {}; + this.eventVarSelection.set(nonBlankString(stored['event_var'])); + + const form = buildParameterForm(schema, stored); + this.form.set(form); + this.formInvalid.set(form.invalid); + + const statusSub = form.statusChanges.subscribe(() => this.formInvalid.set(form.invalid)); + const subscription = form.valueChanges.subscribe((values) => { + this.syncEventVarSelectionFromValues(algorithm.name, values); + this.persistCurrentFormConfig(algorithm.name, untracked(() => this.visibleConfigSchema())); + }); + onCleanup(() => { + subscription.unsubscribe(); + statusSub.unsubscribe(); + }); + }); + } + + /** Forgets the built form and event variable so the next method starts from its stored config. */ + resetForNewAlgorithm(): void { + this.builtFormSignature.set(''); + this.eventVarSelection.set(null); + } + + /** n_splits belongs to the cross-validation row, so it renders under that switch, not in the main form. */ + isCrossValidationField(field: AlgorithmParameterField): boolean { + return field.key === 'n_splits' && (this.canToggleCrossValidation() || this.isCrossValidationOnly()); + } + + fieldLabel(field: EnrichedParameterField): string { + return parameterFieldLabel(field, this.studio.algorithmX()[0]); + } + + bindOptionValue(option: ParameterOption): string { + return optionBindingValue(option); + } + + isMultiSelectOptionSelected(key: string, option: ParameterOption): boolean { + return multiSelectValues(this.form().get(key)?.value).includes(String(this.bindOptionValue(option))); + } + + toggleMultiSelectOption(key: string, option: ParameterOption): void { + const control = this.form().get(key); + if (!control) return; + const value = String(this.bindOptionValue(option)); + const current = multiSelectValues(control.value); + const next = current.includes(value) + ? current.filter((entry) => entry !== value) + : [...current, value]; + control.setValue(next); + control.markAsTouched(); + control.updateValueAndValidity(); + } + + /** Switches between the base method and its cross-validation variant, carrying the parameters over. */ + setCrossValidation(enabled: boolean): void { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) return; + const baseName = this.studio.getCrossValidationBase(algorithm.name) ?? algorithm.name; + const variant = this.studio.getCrossValidationVariant(baseName); + if (!variant) return; + const configs = this.studio.algorithmConfigurations(); + const preservedConfig = { ...(configs[baseName] ?? {}), ...this.form().getRawValue() }; + + this.crossValidationChoices.set(baseName, enabled); + + if (!enabled) { + if (preservedConfig['n_splits'] !== undefined) delete preservedConfig['n_splits']; + if (this.form().contains('n_splits')) this.form().removeControl('n_splits'); + + const variantConfig = { ...(configs[variant] ?? {}) }; + if (variantConfig['n_splits'] !== undefined) delete variantConfig['n_splits']; + this.studio.algorithmConfigurations.set({ + ...configs, + [baseName]: preservedConfig, + [variant]: variantConfig, + }); + } else { + this.studio.algorithmConfigurations.set({ + ...configs, + [baseName]: preservedConfig, + [variant]: { ...(configs[variant] ?? {}), ...preservedConfig }, + }); + } + + this.crossValidation.set(enabled); + } + + /** The parameters a run sends: the visible form values, plus the outlier_report rules. */ + runParameterValues(): Record { + return this.mergeOutlierReportConfig(normalizeParameterValues(this.form().getRawValue(), this.visibleConfigSchema())); + } + + // --- outlier_report rules --- + + /** The variable's rule; a variable without one reads as an enabled default rule. */ + outlierReportRuleFor(variable: OutlierReportVariable): OutlierRule { + return this.outlierReportRules()[variable.code] ?? createDefaultOutlierRule(variable.code, true); + } + + outlierReportRuleError(variable: OutlierReportVariable): string { + return this.outlierReportValidationErrors()[variable.code] || validateOutlierRule(this.outlierReportRuleFor(variable)) || ''; + } + + onOutlierReportEnabledChange(variable: OutlierReportVariable, enabled: boolean): void { + this.patchOutlierRule(variable, { enabled }, () => ''); + } + + onOutlierReportStrategyChange(variable: OutlierReportVariable, strategy: OutlierStrategy): void { + this.patchOutlierRule(variable, { enabled: true, strategy, fold: defaultFoldForStrategy(strategy) }, () => ''); + } + + onOutlierReportTailChange(variable: OutlierReportVariable, tail: OutlierTail): void { + this.patchOutlierRule(variable, { enabled: true, tail }); + } + + onOutlierReportFoldChange(variable: OutlierReportVariable, rawValue: string | number): void { + const value = String(rawValue ?? '').trim(); + const fold = value === '' ? null : Number(value); + this.patchOutlierRule( + variable, + { enabled: true, fold: typeof fold === 'number' && Number.isFinite(fold) ? fold : null }, + (rule) => validateOutlierRule(rule) ?? '', + ); + } + + /** + * Checks the outlier_report rules before a run and persists them when they pass. + * Returns the message that blocks the run, or null (always null for other methods). + */ + validateOutlierReportForRun(): string | null { + if (!this.isOutlierReportSelected()) return null; + + const variables = this.outlierReportVariables(); + if (!variables.length) { + return 'Select at least one numerical variable or covariate for the outlier report.'; + } + + // Every listed variable runs with a rule, so the defaults it reads as become real rules. + this.outlierReportRules.update((rules) => { + const complete = { ...rules }; + variables.forEach((variable) => { complete[variable.code] ??= createDefaultOutlierRule(variable.code, true); }); + return complete; + }); + + const errors: Record = {}; + variables.forEach((variable) => { + const error = validateOutlierRule(this.outlierReportRuleFor(variable)); + if (error) errors[variable.code] = error; + }); + + this.outlierReportValidationErrors.set(errors); + if (Object.keys(errors).length > 0) { + return 'Fix the outlier report configuration before running.'; + } + + const serialized = serializeOutlierRules( + this.outlierReportRules(), + new Set(variables.map((variable) => variable.code)) + ); + if (!serialized) { + return 'Enable at least one numerical variable for the outlier report.'; + } + + this.persistOutlierReportConfig(); + return null; + } + + private resolveSelectedEventVarCode(algorithmName: string): string | null { + const stored = untracked(() => this.studio.algorithmConfigurations())[algorithmName]?.['event_var']; + return nonBlankString(this.eventVarSelection()) ?? nonBlankString(stored); + } + + private syncEventVarSelectionFromValues(algorithmName: string, values: Record): void { + if (!Object.prototype.hasOwnProperty.call(values, 'event_var')) return; + const next = nonBlankString(values['event_var']); + if (this.eventVarSelection() === next) return; + this.eventVarSelection.set(next); + this.syncPositiveClassWithEventVar(algorithmName); + } + + private enumOptionsForVariableCode(varCode: string | null | undefined) { + return enumOptionsForVariableCode(varCode, [...this.studio.algorithmY(), ...this.studio.algorithmX()]); + } + + /** Clears a positive_class that is not a level of the newly chosen event variable. */ + private syncPositiveClassWithEventVar(algorithmName: string): void { + const positiveControl = this.form().get('positive_class'); + if (!positiveControl) return; + + const options = this.enumOptionsForVariableCode(this.resolveSelectedEventVarCode(algorithmName)); + if (!options.length) return; + + const allowed = new Set(options.map((option) => option.code)); + const current = positiveControl.value; + if ( + current !== null && + current !== undefined && + String(current).trim() !== '' && + !allowed.has(String(current)) + ) { + positiveControl.setValue(null, { emitEvent: false }); + } + } + + private persistCurrentFormConfig(algorithmName: string, schema: readonly EnrichedParameterField[]): void { + const values = normalizeParameterValues(this.form().getRawValue(), schema); + const nextValues = algorithmName === AlgorithmNames.OUTLIER_REPORT + ? this.mergeOutlierReportConfig(values) + : values; + const allConfigs = this.studio.algorithmConfigurations(); + const previous = allConfigs[algorithmName]; + if (previous && JSON.stringify(previous) === JSON.stringify(nextValues)) return; + this.studio.algorithmConfigurations.set({ ...allConfigs, [algorithmName]: nextValues }); + } + + private mergeOutlierReportConfig(configValues: Record): Record { + if (!this.isOutlierReportSelected()) return configValues; + OUTLIER_RULE_KEYS.forEach((key) => delete configValues[key]); + const payload = serializeOutlierRules( + this.outlierReportRules(), + new Set(this.outlierReportVariables().map((variable) => variable.code)) + ); + return payload ? { ...configValues, ...payload } : configValues; + } + + private persistOutlierReportConfig(): void { + const algorithm = this.selectedAlgorithm(); + if (!algorithm || algorithm.name !== AlgorithmNames.OUTLIER_REPORT) return; + this.studio.algorithmConfigurations.set({ + ...this.studio.algorithmConfigurations(), + [algorithm.name]: this.runParameterValues(), + }); + } + + /** One rule per listed variable: kept, else restored from the stored config, else a default. */ + private reconcileOutlierReportRules(variables: OutlierReportVariable[], stored: Record): void { + const allowedCodes = new Set(variables.map((variable) => variable.code)); + const hydrated = hydrateOutlierRules(stored, allowedCodes); + const hasStoredRules = !!stored && typeof stored === 'object' && Object.keys(stored?.['strategies'] ?? {}).length > 0; + const current = untracked(this.outlierReportRules); + const enableDefaults = Object.keys(current).length > 0 || !hasStoredRules; + const next: Record = {}; + + variables.forEach(({ code }) => { + const kept = current[code] ?? hydrated[code]; + next[code] = kept ? { ...kept } : createDefaultOutlierRule(code, enableDefaults); + }); + + this.outlierReportRules.set(next); + this.outlierReportValidationErrors.update((errors) => + Object.fromEntries(Object.entries(errors).filter(([code]) => allowedCodes.has(code)))); + } + + /** Applies an edit to one rule, optionally replaces its shown error, and persists the config. */ + private patchOutlierRule( + variable: OutlierReportVariable, + patch: Partial, + errorFor?: (rule: OutlierRule) => string, + ): void { + const rule = { ...this.outlierReportRuleFor(variable), ...patch }; + this.outlierReportRules.update((rules) => ({ ...rules, [variable.code]: rule })); + if (errorFor) { + this.outlierReportValidationErrors.update((errors) => ({ ...errors, [variable.code]: errorFor(rule) })); + } + this.persistOutlierReportConfig(); + } +} + +function hasCode(variable: StudioVariable | null | undefined): variable is OutlierReportVariable { + return !!variable?.code; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css new file mode 100644 index 00000000..6fa9e956 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.css @@ -0,0 +1,270 @@ +@import '../../styles/config-placeholder.shared.css'; +@import '../../styles/sr-only.shared.css'; +@import '../../styles/step-kicker.shared.css'; + +.algorithm-params__body { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.algorithm-params__empty { + display: flex; + flex-direction: column; + flex: 1 1 auto; + border-radius: var(--radius-md, 8px); +} + +.algorithm-params__empty-title { + margin: 0; + font-size: 0.92rem; + font-weight: 700; + color: var(--text-main); + line-height: 1.35; +} + +.algorithm-params__empty-text { + margin: 0; + max-width: 30rem; + font-size: 0.8rem; + line-height: 1.6; + color: var(--text-muted); +} + +/* Run requirements */ +.config-form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.config-field { + flex-direction: column; +} + +/* Fields are divided by a hairline rather than framed: the parameters read as one + form, and the card already paints the only container. */ +.config-field + .config-field { + border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); + padding-top: 10px; +} + +/* Outlier report table */ +.outlier-report-editor { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 0; +} + +/* Shared fieldset host: the unified card paints the frame, not the fieldset. */ +.algorithm-readonly-fieldset { + border: 0; + padding: 0; + margin: 0; + min-width: 0; +} + +/* Needs checklist */ +.algo-needs { + display: grid; + gap: 4px; + margin: 0 0 14px; + padding: 10px 12px; + list-style: none; + border-radius: var(--radius-md); + background: var(--bg-neutral); + font-size: 0.8rem; +} + +.algo-needs-all-met { + margin: 0 0 14px; + padding: 8px 12px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--success) 10%, white); + color: #065f46; + font-size: 0.8rem; + font-weight: 600; +} + +/* Parameters */ +.algorithm-params__title { + margin: 0 0 2px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: var(--letter-spacing-config); + text-transform: uppercase; + color: var(--text-muted); +} + +/* Parameter rows: label beside control, sentence case, optional tag. */ +.config-field { + display: grid; + grid-template-columns: 220px minmax(0, 360px); + gap: 2px 16px; + align-items: start; + padding: 10px 0; +} + +/* Advanced fields stay behind a disclosure, closed by default. */ +.config-advanced { + border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); + margin-top: 4px; +} + +.config-advanced__summary { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 0; + list-style: none; + cursor: pointer; + font-size: 0.84rem; + font-weight: 600; + color: var(--text-main); +} + +.config-advanced__summary::-webkit-details-marker { + display: none; +} + +.config-advanced__chevron { + width: 0.4rem; + height: 0.4rem; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.2s ease; +} + +.config-advanced[open] > .config-advanced__summary .config-advanced__chevron { + transform: rotate(45deg); +} + +.config-advanced__body { + padding-bottom: 6px; +} + +/* Option rows: cross-validation uses the studio switch. */ +.config-option-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 0; + border-top: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); +} + +/* Settings that belong to the switch above sit under it, inset to read as its children. */ +.config-option-detail { + margin: -4px 0 8px; + padding-left: 14px; + border-left: 2px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); +} + +.config-option-detail .config-field { + grid-template-columns: 206px minmax(0, 360px); + padding: 4px 0; +} + +.config-option-row.is-locked { + opacity: 0.86; +} + +.config-option-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.config-option-label { + font-size: 0.84rem; + font-weight: 600; + color: var(--text-main); +} + +.config-option-hint { + font-size: 0.76rem; + line-height: 1.4; + color: var(--text-muted); +} + +.toggle-switch { + position: relative; + display: inline-block; + flex: 0 0 auto; + width: 44px; + height: 24px; +} + +.toggle-switch.is-locked { + cursor: not-allowed; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-switch input:focus-visible + .toggle-slider { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + inset: 0; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--text-muted) 75%, transparent); + background: color-mix(in srgb, var(--text-muted) 22%, white); + transition: background 0.22s ease, border-color 0.22s ease, box-shadow 0.22s ease; +} + +.toggle-slider::before { + content: ''; + position: absolute; + height: 18px; + width: 18px; + left: 3px; + top: 2.5px; + border-radius: 50%; + background: var(--card-bg); + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18), inset 0 0 0 1px rgba(15, 23, 42, 0.12); + transition: transform 0.22s var(--ease-standard, ease), box-shadow 0.22s ease; +} + +.toggle-switch input:checked + .toggle-slider { + background: var(--primary-color); + border-color: rgba(43, 51, 233, 0.24); + box-shadow: 0 5px 14px color-mix(in srgb, var(--primary-color) 22%, transparent); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(20px); +} + +.toggle-switch input:disabled + .toggle-slider { + cursor: not-allowed; +} + +/* Empty state under the ③ kicker, without the dashed frame. */ +.algorithm-params__empty { + align-items: flex-start; + justify-content: flex-start; + gap: 6px; + margin: 0; + padding: 16px; + border: 0; + background: transparent; +} + +@media (max-width: 900px) { + .config-field, + .config-option-detail .config-field { + grid-template-columns: 1fr; + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html new file mode 100644 index 00000000..b9c3c2b1 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.html @@ -0,0 +1,91 @@ +@if (selectedAlgorithm()) { +
+ @if (run.algorithmNeeds().length) { +
    + } @else if (selectedAlgorithm()) { +

    ✓ All inputs match

    + } + +
    + + @if (form.hasAlgorithmParameterControls()) { +

    Parameters

    + } + + @if (form.isOutlierReportSelected()) { +
    + } + + + @if (form.primaryConfigSchema().length || form.advancedConfigSchema().length) { +
    + @for (field of form.primaryConfigSchema(); track field.key) { +
    + } + + @if (form.advancedConfigSchema().length) { +
    + + + Advanced ({{ form.advancedConfigSchema().length }}) + +
    + @for (field of form.advancedConfigSchema(); track field.key) { +
    + } +
    +
    + } +
    + } + + @if (form.canToggleCrossValidation() || form.isCrossValidationOnly()) { +
    +
    + Cross-validation + + {{ form.isCrossValidationOnly() ? 'Always on for this method' : 'Runs the cross-validation variant and reports fold metrics' }} + +
    + +
    + + @if (form.crossValidationConfigSchema().length) { +
    + @for (field of form.crossValidationConfigSchema(); track field.key) { +
    + } +
    + } + } + + @if (!form.hasAlgorithmParameterControls()) { +
    +
    +
    No additional parameters for this algorithm
    +

    + This algorithm does not expose extra configuration fields. Review the + documentation and run the experiment when you are ready. +

    +
    +
    + } + +
    + +
    +} @else { +
    +
    3
    +

    No algorithm selected

    +

    + Select a runnable algorithm from the catalog to configure parameters and run the + experiment. +

    +
    +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts new file mode 100644 index 00000000..f748b479 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-parameters/algorithm-parameters.component.ts @@ -0,0 +1,32 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ExperimentStudioService } from '../../../../../services/experiment-studio.service'; +import { AlgorithmRunService } from '../../experiment-result/algorithm-run.service'; +import { AlgorithmRunRequirementsComponent } from '../../experiment-result/algorithm-run-requirements/algorithm-run-requirements.component'; +import { AlgorithmCatalogService } from '../algorithm-catalog.service'; +import { AlgorithmConfigFormService } from '../algorithm-config-form.service'; +import { AlgorithmConfigFieldComponent } from '../algorithm-config-field/algorithm-config-field.component'; +import { OutlierReportEditorComponent } from '../outlier-report-editor/outlier-report-editor.component'; + +/** + * The selected method's parameters: what still blocks the run, the parameter form with its + * Advanced fields, cross-validation, and the outlier_report editor. Read-only while the method + * is unavailable. + */ +@Component({ + selector: 'section[app-algorithm-parameters]', + imports: [ReactiveFormsModule, AlgorithmConfigFieldComponent, OutlierReportEditorComponent, AlgorithmRunRequirementsComponent], + templateUrl: './algorithm-parameters.component.html', + styleUrl: './algorithm-parameters.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlgorithmParametersComponent { + protected readonly catalog = inject(AlgorithmCatalogService); + protected readonly form = inject(AlgorithmConfigFormService); + protected readonly run = inject(AlgorithmRunService); + protected readonly selectedAlgorithm = inject(ExperimentStudioService).selectedAlgorithm; + + toggleCrossValidation(event: Event): void { + this.form.setCrossValidation(!!(event.target as HTMLInputElement | null)?.checked); + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.css new file mode 100644 index 00000000..d03da9d9 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.css @@ -0,0 +1,331 @@ +@import '../styles/step-kicker.shared.css'; + +.algorithm-roles-board__header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 16px; + padding: 12px 14px; + border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); + background: var(--studio-card-header-bg, #f8fbff); +} + +.algorithm-roles-board__title { + margin: 0 0 2px; + font-weight: 700; + line-height: 1.25; + letter-spacing: 0.01em; + color: var(--text-main); +} + +/* Live runnable/total counter — tabular so filtering never reflows the header. */ +.algorithm-count-badge { + gap: 6px; + flex: 0 0 auto; +} + +.algorithm-count-badge strong { + color: var(--primary-color); + font-size: 1rem; + font-weight: 800; + font-variant-numeric: tabular-nums; + letter-spacing: -0.01em; +} + +.algorithm-count-badge span { + font-variant-numeric: tabular-nums; +} + +/* BOTTOM ROW: Algorithm catalog (left) · Algorithm configuration (right). The row + grows with its content (24rem floor) instead of being pinned to a short fixed + height, so the parameter selection expands and every parameter field is readable + in the normal flow — the window is the scroller rather than a nested inner scroll. + The assignment rails sit above the row in flow, so switching methods never moves + them out from under the pointer; only space below the row changes. */ +.algorithm-workspace { + display: grid; + grid-template-columns: minmax(300px, 20%) minmax(0, 80%); /* 20% catalog · 80% configuration */ + min-width: 0; + min-height: 24rem; + height: auto; + overflow: hidden; +} + +/* TOP BAND: Experiment pool across the full card width — the variable list on the left, + the stacked outcome and predictor rails beside it on the right — so the band only has + to be tall enough for the scrolling list. It is one shrinking grid track rather than a + stretched flex cross-size: that is what lets the band stop at its max-height and hand + the overflow to the list's own scroll, instead of clipping the hint out of the card. */ +.algorithm-pool { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + flex: 0 0 auto; + min-width: 0; + min-height: 0; + border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); +} + +.algorithm-pool > app-algorithm-role-assignment { + min-width: 0; + min-height: 0; +} + +.algorithm-catalog { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px 14px; + min-width: 0; + min-height: 0; + overflow: hidden; + color: var(--text-main); + box-sizing: border-box; + border-right: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); +} + +/* RIGHT OF THE BOTTOM ROW: ALGORITHM CONFIGURATION — documentation and parameter + fields share one column that grows with its content, so a parameter-rich method + reveals its whole form instead of clipping it under a fixed-height inner scroll. + The board paints the frame: this column adds no border, tint, or shadow of its + own. */ +.algorithm-config-column { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + overflow: visible; +} + +/* PARAMETERS — continue the configuration flow directly under the documentation; + the unified card paints the frame. */ +.algorithm-params { + display: flex; + flex-direction: column; + flex: 1 1 auto; + gap: 10px; + padding: 0 16px 16px; + min-width: 0; + box-sizing: border-box; + color: var(--text-main); +} + +.inspector-tag { + display: inline-flex; + align-items: center; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.14)); + background: var(--bg-neutral); + color: var(--text-muted); + font-size: 0.64rem; + font-weight: 700; +} + +/* TOP OF THE CONFIGURATION COLUMN — one continuous Algorithm configuration section: + header, description, and documentation. It no longer pins: the column expands to + its content and the window scrolls, so a sticky head would hang over the scrolling + page and hide the parameter fields below it instead of helping them. */ +.algorithm-details { + display: flex; + flex-direction: column; + gap: 8px; + position: static; + background: var(--studio-card-bg); + color: var(--text-main); +} + +.algorithm-details__header { + flex-direction: column; +} + +.algorithm-details__heading { + flex-wrap: wrap; +} + +/* Documentation */ +.documentation-panel { + margin: 0; +} + +/* The native disclosure triangle is hidden, so the summary states its own affordance: + a chevron that turns with the state, driven by [open] on the
    itself. */ +.documentation-summary { + gap: 8px; + cursor: pointer; + list-style: none; +} + +.documentation-summary::-webkit-details-marker { + display: none; +} + +/* Same chevron geometry as the catalog groups: right when collapsed, down when open. */ +.documentation-chevron { + flex: 0 0 auto; + width: 0.45rem; + height: 0.45rem; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(-45deg); + transition: transform 0.2s ease; +} + +.documentation-panel[open] > .documentation-summary .documentation-chevron { + transform: rotate(45deg); +} + +.documentation-content { + font-size: 0.82rem; + line-height: 1.45; + color: rgba(20, 22, 31, 0.72); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.config-description { + margin: 0; + font-size: 0.86rem; + line-height: 1.55; + color: var(--text-muted); +} + +/* RESPONSIVE LAYOUT */ +@media (max-width: 960px) { + .algorithm-workspace { + grid-template-columns: minmax(0, 1fr); + height: auto; + min-height: 0; + } + /* Stacked: head, rails, list and hint sit in one column, so the band hugs that + taller content and the window scrolls rather than the band scrolling inside it. */ + .algorithm-pool { + border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); + } + .algorithm-catalog { + border-right: 0; + border-bottom: 1px solid var(--studio-card-header-border, rgba(43, 51, 233, 0.08)); + max-height: 24rem; + overflow: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .documentation-chevron { + transition: none; + } +} + +.algorithm-roles-board__title { + font-size: 0.96rem; +} + +.algorithm-count-badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border: 0; + border-radius: 999px; + background: color-mix(in srgb, var(--primary-color) 8%, white); + color: var(--primary-color); + font-size: 0.72rem; + font-weight: 600; + white-space: nowrap; +} + +.algorithm-pool { + height: auto; + max-height: none; + overflow: visible; +} + +.algorithm-pool > app-algorithm-role-assignment { + overflow: visible; +} + +/* Configuration head: number ring, real method label, category tag. */ +.algorithm-details { + padding: 14px 16px 8px; +} + +.algorithm-details__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.algorithm-details__heading { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.algorithm-details__title { + margin: 0; + min-width: 0; + font-size: 1.1rem; + font-weight: 700; + line-height: 1.3; + color: var(--text-main); + overflow-wrap: anywhere; +} + +.inspector-tag { + flex: 0 0 auto; + text-transform: none; + letter-spacing: 0; +} + +.config-description { + max-width: 60ch; + margin-top: 6px; +} + +/* Documentation is a quiet text link by default. */ +.documentation-panel { + margin-top: 2px; +} + +.documentation-summary { + display: inline-flex; + align-items: center; + padding: 2px 0; + margin: 0; + border-radius: 0; + background: none; + font-size: 0.8rem; + font-weight: 600; + color: var(--primary-color); +} + +.documentation-summary:hover { + background: none; + text-decoration: underline; +} + +.documentation-chevron { + display: none; +} + +.documentation-panel[open] > .documentation-summary { + border-bottom: 0; + border-radius: 0; +} + +.documentation-content { + margin-top: 6px; + padding-left: 0; + border-left: 0; +} + +@media (max-width: 900px) { + .algorithm-details__header { + flex-wrap: wrap; + } + .algorithm-workspace { + grid-template-columns: 1fr; + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.html new file mode 100644 index 00000000..736870a4 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.html @@ -0,0 +1,46 @@ +
    +
    +
    Algorithm
    +
    + {{ catalog.algoCounts().active }} of {{ catalog.algoCounts().all }} runnable +
    + +
    + +
    + +
    + + +
    + @if (selectedAlgorithm(); as algorithm) { +
    +
    +
    +
    3
    +

    {{ algorithm.label }}

    +
    + @if (selectedAlgorithmCategory()) { + {{ selectedAlgorithmCategory() }} + } +
    + @if (algorithm.description) { +

    {{ algorithm.description }}

    + } + @if (selectedAlgorithmDocumentation()) { +
    + + + How this method works + +
    {{ selectedAlgorithmDocumentation() }}
    +
    + } +
    + } + +
    +
    +
    + + diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.ts new file mode 100644 index 00000000..68006f42 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/algorithm-selection.component.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { AlgorithmRoleAssignmentComponent } from '../algorithm-role-assignment/algorithm-role-assignment.component'; +import { AlgorithmCatalogComponent } from './algorithm-catalog/algorithm-catalog.component'; +import { AlgorithmParametersComponent } from './algorithm-parameters/algorithm-parameters.component'; +import { AlgorithmCatalogService } from './algorithm-catalog.service'; + +/** + * The algorithm card: role assignment, the method catalog, and the selected method's details + * and parameters. Attached to the card element the panel renders, so the DOM and its anchors + * are unchanged; the panel projects the run bar at the bottom of the card. + */ +@Component({ + selector: 'section[app-algorithm-selection]', + imports: [AlgorithmRoleAssignmentComponent, AlgorithmCatalogComponent, AlgorithmParametersComponent], + templateUrl: './algorithm-selection.component.html', + styleUrl: './algorithm-selection.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlgorithmSelectionComponent { + protected readonly catalog = inject(AlgorithmCatalogService); + protected readonly selectedAlgorithm = inject(ExperimentStudioService).selectedAlgorithm; + protected readonly selectedAlgorithmDocumentation = computed(() => this.selectedAlgorithm()?.documentation?.trim() ?? ''); + protected readonly selectedAlgorithmCategory = computed(() => this.selectedAlgorithm()?.category?.trim() ?? ''); +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.css b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.css new file mode 100644 index 00000000..80480011 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.css @@ -0,0 +1,102 @@ +@import '../../styles/config-placeholder.shared.css'; +@import '../../styles/form-controls.shared.css'; + +.outlier-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.outlier-editor-header h6 { + margin: 0; + font-size: 0.9rem; + font-weight: 700; + color: var(--text-main); +} + +.outlier-count { + font-size: 0.72rem; + font-weight: 600; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.outlier-table-wrapper { + overflow-x: auto; +} + +.outlier-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.outlier-table th { + padding: 6px 8px; + text-align: left; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); +} + +.outlier-table td { + padding: 6px 8px; + vertical-align: top; + border-bottom: 1px solid var(--studio-card-border, rgba(43, 51, 233, 0.1)); +} + +.outlier-table tr:last-child td { + border-bottom: 0; +} + +.outlier-variable-label { + display: block; + font-weight: 600; + color: var(--text-main); +} + +.outlier-table small { + color: var(--text-muted); + font-size: 0.68rem; +} + +.outlier-table .compact-select, +.outlier-table .compact-input { + min-width: 6.5rem; + padding: 5px 8px; + font-size: 0.78rem; +} + +.outlier-enable { + min-height: 24px; +} + +/* Muted validation: invalid keeps a danger edge; valid is neutral. Kept in this sheet rather + than in the form-controls partial: it must come after the rules above to win their ties. */ +.config-input.ng-valid.ng-touched, +.config-select.ng-valid.ng-touched { + border: 1px solid rgba(148, 163, 184, 0.34); + background: #fff; + background-image: none; + box-shadow: none; + padding-right: 12px; +} + +.config-input.ng-invalid.ng-touched, +.config-select.ng-invalid.ng-touched { + border: 1px solid var(--danger); + background-color: #fff; + background-image: none; + box-shadow: none; + padding-right: 12px; +} + +.config-input.ng-invalid.ng-touched:focus, +.config-select.ng-invalid.ng-touched:focus { + border-color: var(--danger); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 16%, transparent); +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.html b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.html new file mode 100644 index 00000000..aafc4ff6 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.html @@ -0,0 +1,70 @@ +
    +
    +
    Per-variable Outlier Rules
    +
    + {{ form.outlierReportVariables().length }} numerical +
    + +@if (form.outlierReportVariables().length > 0) { +
    + + + + + + + + + + + + @for (variable of form.outlierReportVariables(); track variable.code) { + + + + + + + + } + +
    VariableEnabledStrategyTailFold
    + {{ variableLabel(variable) }} + {{ variable.code }} + + + + + + + + + @if (form.outlierReportRuleError(variable)) { +
    {{ form.outlierReportRuleError(variable) }}
    + } +
    +
    +} @else { +
    +
    +
    No numerical variables selected
    +
    +
    +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.ts new file mode 100644 index 00000000..d975cf64 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-selection/outlier-report-editor/outlier-report-editor.component.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { OUTLIER_STRATEGIES, OUTLIER_TAILS } from '../../../../../core/outlier-rules'; +import { OutlierReportVariable } from '../../algorithm-panel.models'; +import { AlgorithmConfigFormService } from '../algorithm-config-form.service'; + +/** outlier_report's per-variable rules: enabled, strategy, tail and fold for each numerical variable. */ +@Component({ + selector: 'section[app-outlier-report-editor]', + imports: [FormsModule], + templateUrl: './outlier-report-editor.component.html', + styleUrl: './outlier-report-editor.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class OutlierReportEditorComponent { + protected readonly form = inject(AlgorithmConfigFormService); + protected readonly outlierStrategies = OUTLIER_STRATEGIES; + protected readonly outlierTails = OUTLIER_TAILS; + + variableLabel(variable: OutlierReportVariable): string { + return variable?.label ?? variable?.name ?? variable?.code ?? ''; + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/algorithm-studio-substep.service.ts b/src/app/pages/experiment-studio/algorithm-panel/algorithm-studio-substep.service.ts new file mode 100644 index 00000000..297832e3 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/algorithm-studio-substep.service.ts @@ -0,0 +1,21 @@ +import { Injectable, signal } from '@angular/core'; +import { Subject } from 'rxjs'; +import { AlgorithmStudioSubstep } from './algorithm-panel.models'; + +/** + * The algorithm studio's sub-step: setup (roles and method), then parameters. Provided by the + * algorithm panel, which re-emits every change as its `studioSubstepChange` output. + */ +@Injectable() +export class AlgorithmStudioSubstepService { + private readonly step = signal('setup'); + private readonly changes = new Subject(); + + readonly current = this.step.asReadonly(); + readonly changed$ = this.changes.asObservable(); + + set(step: AlgorithmStudioSubstep): void { + this.step.set(step); + this.changes.next(step); + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.css b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.css new file mode 100644 index 00000000..7d42dca0 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.css @@ -0,0 +1,27 @@ +@import '../../styles/sr-only.shared.css'; + +:host(.algo-needs) li { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; +} + +.algo-needs-mark { + width: 12px; + height: 12px; + border-radius: 50%; + box-shadow: inset 0 0 0 1.5px #a06b10; + transform: translateY(2px); +} + +:host(.algo-needs) li.is-met .algo-needs-mark { + background: var(--success); + box-shadow: none; +} + +:host(.algo-needs) small { + flex-basis: 100%; + padding-left: 20px; + color: #78350f; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.html b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.html new file mode 100644 index 00000000..d85d8227 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.html @@ -0,0 +1,15 @@ +
  • Complete before running
  • +@for (need of run.algorithmNeeds(); track need.key) { +
  • + + {{ need.title }} + @for (message of need.messages; track message) { + {{ message }} + } + @if (need.action) { + + } +
  • +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts new file mode 100644 index 00000000..c3385af6 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run-requirements/algorithm-run-requirements.component.ts @@ -0,0 +1,33 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { ExperimentStudioNavigationService } from '../../../../../services/experiment-studio-navigation.service'; +import { AlgorithmStudioSubstepService } from '../../algorithm-studio-substep.service'; +import { AlgorithmRunRequirement } from '../../algorithm-panel.models'; +import { AlgorithmRunService } from '../algorithm-run.service'; + +/** What the selected method still needs before it can run, each with the action that fixes it. */ +@Component({ + selector: 'ul[app-algorithm-run-requirements]', + templateUrl: './algorithm-run-requirements.component.html', + styleUrl: './algorithm-run-requirements.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AlgorithmRunRequirementsComponent { + private readonly studioNavigation = inject(ExperimentStudioNavigationService); + private readonly substep = inject(AlgorithmStudioSubstepService); + protected readonly run = inject(AlgorithmRunService); + + resolveRunRequirement(requirement: AlgorithmRunRequirement): void { + if (requirement.id === 'preprocessing') { + this.studioNavigation.goToPreprocessing(); + return; + } + // Availability issues are fixed on the setup board, which stays on screen above. + this.substep.set('setup'); + requestAnimationFrame(() => { + // `scroll-margin-top` on the target clears the sticky chrome. + const target = document.querySelector('[data-guide="guide-role-assignment"]'); + target?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + target?.focus({ preventScroll: true }); + }); + } +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.spec.ts new file mode 100644 index 00000000..0145498b --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.spec.ts @@ -0,0 +1,216 @@ +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { AuthService } from '../../../../services/auth.service'; +import { CsvExportService } from '../../../../services/csv-export.service'; +import { ErrorService } from '../../../../services/error.service'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { PdfExportService } from '../../../../services/pdf-export.service'; +import { RuntimeEnvService } from '../../../../services/runtime-env.service'; +import { AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { AlgorithmCatalogService } from '../algorithm-selection/algorithm-catalog.service'; +import { AlgorithmConfigFormService } from '../algorithm-selection/algorithm-config-form.service'; +import { StudioVariable } from '../algorithm-panel.models'; +import { AlgorithmRunService } from './algorithm-run.service'; + +const algorithm: AlgorithmConfig = { + name: 'flat_config_algorithm', + label: 'Flat Config Algorithm', + description: '', + category: 'Test', + requiredVariable: [], + covariate: [], + configSchema: [{ key: 'first', label: 'First', type: 'number', default: 1 }], + isDisabled: false, +}; + +const outlierAlgorithm: AlgorithmConfig = { + name: 'outlier_report', + label: 'Outlier Report', + description: '', + category: 'Descriptive Statistics', + requiredVariable: ['real'], + covariate: ['real'], + configSchema: [ + { key: 'strategies', label: 'Strategies', type: 'dict' }, + { key: 'tails', label: 'Tails', type: 'dict' }, + { key: 'folds', label: 'Folds', type: 'dict' }, + ], + isDisabled: false, +}; + +describe('AlgorithmRunService', () => { + let studio: ReturnType; + let run: AlgorithmRunService; + let configForm: AlgorithmConfigFormService; + + function createStudio() { + return { + selectedAlgorithm: signal(algorithm), + algorithmY: signal([]), + algorithmX: signal([]), + algorithmAssignableVariables: signal([]), + unavailableVariables: signal>(new Map()), + selectedFilters: signal([]), + selectedDatasets: signal([]), + selectedDataModel: signal(null), + algorithmConfigurations: signal>>({}), + availableGroupedAlgorithms: signal>({ Test: [algorithm] }), + backendAlgorithms: signal>({ flat_config_algorithm: algorithm }), + isRunning: signal(false), + lastUsedAlgorithm: signal(''), + runResult: signal | null>(null), + runId: signal(null), + runError: signal(null), + lastRunSchema: signal([]), + hasRunStarted: signal(false), + runStatusText: signal(''), + variableLabelMap: signal>({}), + filterLogic: signal(null), + getCategoricalEnumMaps: vi.fn().mockReturnValue({}), + isCrossValidationOnly: vi.fn().mockReturnValue(false), + getCrossValidationBase: vi.fn().mockReturnValue(null), + getCrossValidationVariant: vi.fn().mockReturnValue(null), + isCrossValidationAlgorithm: vi.fn().mockReturnValue(false), + hasAppliedDescriptivePreprocessing: vi.fn().mockReturnValue(true), + hasRequestPreprocessingForRun: vi.fn().mockReturnValue(false), + isAlgorithmAvailable: vi.fn().mockReturnValue(true), + getAlgorithmAvailability: vi.fn().mockReturnValue({ available: true, summary: '', details: [] }), + setRunning: vi.fn(), + runSelectedAlgorithm: vi.fn(), + captureRunSetup: vi.fn(), + getDatasetLabelMap: vi.fn().mockReturnValue({}), + getEffectivePreprocessingEntries: vi.fn().mockReturnValue([]), + }; + } + + beforeEach(() => { + studio = createStudio(); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + { provide: ExperimentStudioService, useValue: studio }, + { provide: ErrorService, useValue: { clearError: vi.fn() } }, + { provide: AuthService, useValue: { currentUser: null } }, + { provide: PdfExportService, useValue: { exportExperimentPdf: vi.fn() } }, + { provide: CsvExportService, useValue: { exportExperimentZip: vi.fn() } }, + { provide: RuntimeEnvService, useValue: { mipVersion: 'test' } }, + AlgorithmCatalogService, + AlgorithmConfigFormService, + AlgorithmRunService, + ], + }); + configForm = TestBed.inject(AlgorithmConfigFormService); + run = TestBed.inject(AlgorithmRunService); + TestBed.tick(); + }); + + describe('variables missing from a selected dataset', () => { + const unavailable = new Map([ + ['age', ['Dataset B']], + ['sex', ['Dataset B', 'Dataset C']], + ]); + + it('blocks the run while an outcome, predictor or filter is unavailable', () => { + studio.unavailableVariables.set(unavailable); + studio.algorithmY.set([{ code: 'age', label: 'Age' }]); + studio.algorithmX.set([{ code: 'mmse', label: 'MMSE' }]); + studio.selectedFilters.set([{ code: 'sex', label: 'Sex' }]); + + expect(run.canRun()).toBe(false); + expect(run.runDisabledReason()).toBe('Remove variables not available in: Dataset B, Dataset C'); + expect(run.algorithmNeeds()).toContainEqual(expect.objectContaining({ + key: 'unavailable-variables', + met: false, + title: 'Remove variables not available in: Dataset B, Dataset C', + })); + + run.runExperiment(); + expect(studio.runSelectedAlgorithm).not.toHaveBeenCalled(); + expect(studio.setRunning).not.toHaveBeenCalled(); + }); + + it('checks predictors on their own', () => { + studio.unavailableVariables.set(unavailable); + studio.algorithmX.set([{ code: 'age', label: 'Age' }]); + + expect(run.canRun()).toBe(false); + expect(run.runDisabledReason()).toBe('Remove variables not available in: Dataset B'); + }); + + it('runs again once the unavailable variables are removed', () => { + studio.unavailableVariables.set(unavailable); + studio.algorithmY.set([{ code: 'age', label: 'Age' }]); + expect(run.canRun()).toBe(false); + + studio.algorithmY.set([{ code: 'mmse', label: 'MMSE' }]); + expect(run.canRun()).toBe(true); + expect(run.runDisabledReason()).toBe('Execute experiment'); + expect(run.algorithmNeeds().some((need) => need.key === 'unavailable-variables')).toBe(false); + }); + + it('ignores unavailable variables the run does not use', () => { + studio.unavailableVariables.set(unavailable); + studio.algorithmAssignableVariables.set([{ code: 'age', label: 'Age' }]); + + expect(run.canRun()).toBe(true); + }); + }); + + it('shows the run error and stops running when the run request fails', () => { + studio.runSelectedAlgorithm.mockReturnValue(throwError(() => new Error('500'))); + + run.runExperiment(); + + expect(studio.runError()).toBe('Unable to run experiment. Please try again.'); + expect(studio.runId()).toBeNull(); + expect(studio.setRunning).toHaveBeenCalledWith(true); + expect(studio.setRunning).toHaveBeenLastCalledWith(false); + }); + + describe('outlier_report', () => { + const age = { code: 'age', label: 'Age', type: 'real' }; + const bmi = { code: 'bmi', label: 'BMI', type: 'real' }; + + beforeEach(() => { + studio.selectedAlgorithm.set(outlierAlgorithm); + studio.backendAlgorithms.set({ outlier_report: outlierAlgorithm }); + studio.hasAppliedDescriptivePreprocessing.mockReturnValue(false); + }); + + it('sends the rules as dictionary parameters and keeps the run', () => { + studio.algorithmAssignableVariables.set([age, { code: 'sex', label: 'Sex', type: 'nominal' }, bmi]); + studio.runId.set('stale-run'); + studio.runSelectedAlgorithm.mockReturnValue(of({ uuid: 'kept-run', status: 'success', result: { featurewise: [] } })); + TestBed.tick(); + + configForm.onOutlierReportEnabledChange(bmi, false); + configForm.onOutlierReportStrategyChange(age, 'quantile'); + configForm.onOutlierReportFoldChange(age, 0.05); + run.runExperiment(); + + expect(studio.algorithmConfigurations()['outlier_report']).toEqual({ + strategies: { age: 'quantile' }, + tails: { age: 'both' }, + folds: { age: 0.05 }, + }); + expect(studio.captureRunSetup).toHaveBeenCalledWith('outlier_report'); + expect(studio.runSelectedAlgorithm).toHaveBeenCalledWith('outlier_report', 'outlier_report'); + // The kept run is what Save persists, so it must be the run behind the shown result. + expect(studio.runId()).toBe('kept-run'); + }); + + it('blocks invalid folds', () => { + studio.algorithmAssignableVariables.set([age]); + TestBed.tick(); + + configForm.onOutlierReportStrategyChange(age, 'quantile'); + configForm.onOutlierReportFoldChange(age, 0.5); + run.runExperiment(); + + expect(studio.runSelectedAlgorithm).not.toHaveBeenCalled(); + expect(run.errorMsg()).toBe('Fix the outlier report configuration before running.'); + expect(studio.setRunning).toHaveBeenLastCalledWith(false); + }); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts new file mode 100644 index 00000000..4da9ec05 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/algorithm-run.service.ts @@ -0,0 +1,399 @@ +import { Injectable, computed, effect, inject, signal } from '@angular/core'; +import { ExperimentStudioService } from '../../../../services/experiment-studio.service'; +import { ErrorService } from '../../../../services/error.service'; +import { AuthService } from '../../../../services/auth.service'; +import { RuntimeEnvService } from '../../../../services/runtime-env.service'; +import { experimentTables, PdfExportService } from '../../../../services/pdf-export.service'; +import { CsvExportService } from '../../../../services/csv-export.service'; +import { getOutputSchema, prettifyLabel as toHumanLabel } from '../../../../core/algorithm-mappers'; +import { formatFilterExpression } from '../../../../core/filter-display.utils'; +import { AlgorithmNames } from '../../../../core/constants/algorithm.constants'; +import { unavailableDatasetsFor, unavailableVariableTooltip } from '../../../../core/variable-availability.utils'; +import { formatAvailabilityMessage } from '../utils/algorithm-availability-text.utils'; +import { buildAlgorithmNeeds, readinessText, resolveRunAlgorithmNames } from '../utils/algorithm-run.utils'; +import { AlgorithmCatalogService } from '../algorithm-selection/algorithm-catalog.service'; +import { AlgorithmConfigFormService } from '../algorithm-selection/algorithm-config-form.service'; +import { + AlgorithmNeedItem, + AlgorithmRunRequirement, + ExperimentRunInfo, + StudioVariable, +} from '../algorithm-panel.models'; + +/** + * Running the selected method and exporting its result: what still blocks the run, the + * run request itself, its error, and the PDF and ZIP exports. Provided by the algorithm panel. + */ +@Injectable() +export class AlgorithmRunService { + private readonly studio = inject(ExperimentStudioService); + private readonly catalog = inject(AlgorithmCatalogService); + private readonly configForm = inject(AlgorithmConfigFormService); + private readonly errorService = inject(ErrorService); + private readonly authService = inject(AuthService); + private readonly pdfExport = inject(PdfExportService); + private readonly csvExport = inject(CsvExportService); + private readonly mipVersion = inject(RuntimeEnvService).mipVersion; + + /** Why the last run attempt or export failed, shown in the run bar. */ + readonly errorMsg = signal(null); + /** The algorithm of the last successful run; the exports name it. */ + private readonly lastRunAlgorithm = signal(''); + private readonly selectedAlgorithm = this.studio.selectedAlgorithm; + private readonly labelMap = this.studio.variableLabelMap; + + private readonly selectedAlgorithmAvailabilitySummary = computed(() => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm || !this.catalog.selectedAlgorithmUnavailable()) return ''; + const summary = this.studio.getAlgorithmAvailability(algorithm.name).summary ?? algorithm.availability?.summary ?? ''; + return formatAvailabilityMessage(summary); + }); + + readonly preprocessingRunBlockMessage = computed(() => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) return null; + if (algorithm.name === AlgorithmNames.OUTLIER_REPORT) return null; + if ( + this.studio.hasAppliedDescriptivePreprocessing() || + this.studio.hasRequestPreprocessingForRun(algorithm.name) + ) return null; + return 'Apply missing value preprocessing before running algorithms.'; + }); + + /** Problems the user fixes elsewhere in the studio, each with the action that gets there. */ + readonly algorithmRunRequirements = computed((): AlgorithmRunRequirement[] => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) return []; + + const requirements: AlgorithmRunRequirement[] = []; + if (this.catalog.selectedAlgorithmUnavailable()) { + requirements.push({ + id: 'availability', + message: this.selectedAlgorithmAvailabilitySummary() || 'Algorithm requirements are not satisfied.', + actionLabel: this.availabilityRequirementActionLabel(algorithm.name), + }); + } + const preprocessingMessage = this.preprocessingRunBlockMessage(); + if (preprocessingMessage) { + requirements.push({ id: 'preprocessing', message: preprocessingMessage, actionLabel: 'Go to preprocessing' }); + } + return requirements; + }); + + /** + * "Remove variables not available in: …" when a variable the run uses (outcome, predictor + * or cohort filter) is missing from a selected dataset; '' otherwise. Such variables stay + * in the pool grayed out, so the run is blocked until the user removes them. + */ + readonly unavailableRunVariablesReason = computed(() => { + const map = this.studio.unavailableVariables(); + if (!map.size) return ''; + const datasets = new Set(); + for (const node of [ + ...this.studio.algorithmY(), + ...this.studio.algorithmX(), + ...this.studio.selectedFilters(), + ]) { + unavailableDatasetsFor(map, { code: node?.code }).forEach((label) => datasets.add(label)); + } + return unavailableVariableTooltip([...datasets], 'Remove variables not available in'); + }); + + readonly algorithmNeeds = computed(() => { + const algorithm = this.selectedAlgorithm(); + if (!algorithm) return []; + return buildAlgorithmNeeds({ + algorithm, + details: this.catalog.availabilityDetails(algorithm), + hasOutcome: this.studio.algorithmY().length > 0, + unavailableVariablesReason: this.unavailableRunVariablesReason(), + requirements: this.algorithmRunRequirements(), + }); + }); + + readonly canRun = computed(() => ( + !!this.selectedAlgorithm() && + !this.studio.isRunning() && + !this.catalog.selectedAlgorithmUnavailable() && + !this.unavailableRunVariablesReason() && + !this.preprocessingRunBlockMessage() && + !this.configForm.invalid() + )); + + readonly runDisabledReason = computed(() => { + if (this.studio.isRunning()) return 'Experiment is running'; + if (!this.selectedAlgorithm()) return 'Select an algorithm to run'; + if (this.catalog.selectedAlgorithmUnavailable()) return 'Algorithm unavailable for the current selection'; + const unavailable = this.unavailableRunVariablesReason(); + if (unavailable) return unavailable; + const preprocessing = this.preprocessingRunBlockMessage(); + if (preprocessing) return preprocessing; + if (this.configForm.invalid()) return 'Complete variable and algorithm selection to proceed'; + return 'Execute experiment'; + }); + + readonly readinessText = computed(() => readinessText({ + algorithm: this.selectedAlgorithm(), + canRun: this.canRun(), + needs: this.algorithmNeeds(), + y: this.studio.algorithmY(), + x: this.studio.algorithmX(), + })); + + private readonly outputSchema = computed(() => getOutputSchema(this.selectedAlgorithm()?.name ?? '') ?? []); + + private readonly datasetsWithLabels = computed(() => { + const labelByCode = this.studio.getDatasetLabelMap(); + const fallbackMap = this.labelMap(); + return this.studio.selectedDatasets().map((code) => ({ + code, + label: labelByCode[code] ?? fallbackMap[code] ?? toHumanLabel(code), + })); + }); + + /** Name, roles, filters and parameters of the run the exports describe. */ + private readonly experimentInfo = computed((): ExperimentRunInfo => { + const selected = this.studio.selectedAlgorithm(); + const lastRunName = this.studio.lastUsedAlgorithm() || this.lastRunAlgorithm(); + const algoName = selected?.name || lastRunName || null; + const algoMeta = algoName ? this.studio.backendAlgorithms()[algoName] : null; + const displayLabel = selected?.label || algoMeta?.label || algoName || 'N/A'; + const allConfigs = this.studio.algorithmConfigurations(); + + return { + experimentName: `Experiment for ${displayLabel}`, + variables: this.studio.algorithmY(), + covariates: this.studio.algorithmX(), + filters: this.studio.selectedFilters(), + algorithmConfigs: (algoName && allConfigs[algoName]) || {}, + }; + }); + + constructor() { + effect(() => { + // Establish dependencies + this.selectedAlgorithm(); + this.studio.algorithmY(); + this.studio.algorithmX(); + + // A new selection invalidates whatever the last run produced + this.studio.runResult.set(null); + this.studio.runError.set(null); + }); + + effect(() => { + const res = this.studio.runResult(); + const schema = this.outputSchema(); + if (!res || !schema.length) return; + + const missing = schema.filter((field) => !(field.key in res)); + if (missing.length > 0) { + console.warn('[Validation] Missing fields from result:', missing.map((field) => field.key)); + } + }); + } + + /** Drops the previous run's result and errors, as picking another method does. */ + clearRunState(): void { + this.studio.runResult.set(null); + this.studio.runError.set(null); + this.errorMsg.set(null); + } + + /** Sends the selected method with its parameters, unless something still blocks the run. */ + runExperiment(): void { + this.errorMsg.set(null); + this.errorService.clearError(); + + if (this.catalog.selectedAlgorithmUnavailable()) { + this.errorMsg.set('This algorithm is unavailable for the current selection.'); + return; + } + + const unavailableVariables = this.unavailableRunVariablesReason(); + if (unavailableVariables) { + this.errorMsg.set(unavailableVariables); + return; + } + + const preprocessingMessage = this.preprocessingRunBlockMessage(); + if (preprocessingMessage) { + this.errorMsg.set(preprocessingMessage); + return; + } + + // algorithm does not run if form is invalid + const form = this.configForm.configForm(); + if (form.invalid) { + form.markAllAsTouched(); + console.warn('[AlgorithmPanel] Run blocked – configForm invalid'); + return; + } + + const algo = this.selectedAlgorithm(); + if (!algo) { + this.errorMsg.set('Please choose an algorithm before running.'); + return; + } + + this.studio.runStatusText.set('Processing experiment...'); + this.studio.hasRunStarted.set(true); + this.studio.runError.set(null); + this.studio.runResult.set(null); + this.studio.runId.set(null); + this.studio.setRunning(true); + + const outlierReportError = this.configForm.validateOutlierReportForRun(); + if (outlierReportError) { + this.errorMsg.set(outlierReportError); + this.studio.setRunning(false); + return; + } + + const { + baseName: baseAlgorithmName, + effectiveName: effectiveAlgorithmName, + finalName: finalAlgorithmName, + includeSplits: shouldIncludeSplits, + } = resolveRunAlgorithmNames(algo.name, this.configForm.crossValidationEnabled(), this.studio); + + this.studio.lastUsedAlgorithm.set(finalAlgorithmName); + + const configValues = this.configForm.runParameterValues(); + if (!shouldIncludeSplits && configValues['n_splits'] !== undefined) { + delete configValues['n_splits']; + } + this.studio.algorithmConfigurations.set({ + ...this.studio.algorithmConfigurations(), + [baseAlgorithmName]: configValues, + ...(effectiveAlgorithmName !== baseAlgorithmName ? { [effectiveAlgorithmName]: configValues } : {}) + }); + + // Freeze the setup before the request leaves: the Execution step describes the run from + // this snapshot, and parameter edits after the run must not rewrite it. + this.studio.captureRunSetup(finalAlgorithmName); + + const result$ = this.studio.runSelectedAlgorithm(baseAlgorithmName, finalAlgorithmName); + if (!result$) { + this.errorMsg.set('Unable to start the run. Check your selections.'); + this.studio.setRunning(false); + this.studio.hasRunStarted.set(false); + return; + } + + result$.subscribe({ + next: (res) => { + const payload = res?.result ?? {}; + if (res?.status === 'error') { + this.studio.runError.set(payload?.data || payload?.message || 'The server returned an error for this run.'); + return; + } + + const schema = getOutputSchema(finalAlgorithmName ?? '') ?? []; + this.studio.runResult.set({ ...res?.result ?? { message: "No result returned" } }); + // Save persists this kept run, so the saved experiment is the result shown here. + this.studio.runId.set(res?.uuid ?? null); + this.lastRunAlgorithm.set(finalAlgorithmName); + this.studio.lastRunSchema.set(schema); + }, + error: () => { + this.studio.runError.set('Unable to run experiment. Please try again.'); + // An errored stream never completes, so the run state is released here too. + this.studio.setRunning(false); + }, + complete: () => { + this.studio.setRunning(false); + } + }); + } + + /** Exports the shown result and its setup as a PDF report, with the figures in `section`. */ + exportPdf(section: HTMLElement): void { + const payload = this.resultExportPayload(); + if (!section || !payload) { + console.warn('No result or element to export'); + return; + } + this.pdfExport.exportExperimentPdf({ ...payload, chartContainer: section }); + } + + /** Exports the shown result's tables and setup as a ZIP of CSV files. */ + exportZip(): void { + const payload = this.resultExportPayload(); + if (!payload) { + console.warn('No result to export'); + return; + } + this.csvExport.exportExperimentZip({ + ...payload, + tables: experimentTables(payload.algorithmKey, payload.result), + }).catch((error) => { + console.error('Result ZIP export failed:', error); + this.errorMsg.set('Failed to export the result tables.'); + }); + } + + private availabilityRequirementActionLabel(algorithmName: string): string { + const failingDetail = this.studio + .getAlgorithmAvailability(algorithmName) + .details + .find((detail) => !detail.satisfied && detail.messages.length > 0); + return failingDetail?.role === 'x' ? 'Assign predictor' : 'Assign outcome'; + } + + /** The run's setup and result, shared by the PDF report and the ZIP export. */ + private resultExportPayload() { + const result = this.studio.runResult(); + if (!result) return null; + + const info = this.experimentInfo(); + const algoKey = + this.lastRunAlgorithm() || + this.studio.lastUsedAlgorithm() || + this.studio.selectedAlgorithm()?.name || + 'experiment'; + + const algoLabel = this.studio.backendAlgorithms()[algoKey]?.label || algoKey; + + const currentUser = this.authService.currentUser; + const createdBy = currentUser?.fullname || currentUser?.username || currentUser?.email || null; + + const filterLogic = this.studio.filterLogic(); + const enumMaps = this.studio.getCategoricalEnumMaps(); + const filterExpression = filterLogic + ? formatFilterExpression(filterLogic, { labelMap: this.labelMap(), enumMaps }) + : ''; + const model = this.studio.selectedDataModel(); + + return { + filename: info.experimentName, + details: { + experimentName: info.experimentName, + createdBy, + createdAt: new Date(), + algorithm: algoLabel, + params: info.algorithmConfigs, + preprocessing: this.studio + .getEffectivePreprocessingEntries(algoKey, this.labelMap(), enumMaps) + .map((entry) => `${entry.label}: ${entry.value}`) + .join('\n'), + domain: model ? [model.label || model.code, model.version].filter(Boolean).join(' ') : null, + datasets: this.datasetsWithLabels().map((dataset) => dataset.label), + variables: (info.variables ?? []).map(variableDisplayName), + covariates: (info.covariates ?? []).map(variableDisplayName), + filters: filterExpression + ? [filterExpression] + : (info.filters ?? []).map(variableDisplayName), + transformations: null, + mipVersion: this.mipVersion, + }, + algorithmKey: algoKey, + result, + }; + } +} + +/** How exports name a variable: label, then name, then code. */ +function variableDisplayName(variable: StudioVariable): string { + return variable.label || variable.name || variable.code || ''; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.css b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.css new file mode 100644 index 00000000..997d419b --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.css @@ -0,0 +1,27 @@ +.algorithm-run-status { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.algorithm-run-dot { + flex: 0 0 auto; + width: 8px; + height: 8px; + border-radius: 50%; + background: #b45309; +} + +:host(.algorithm-run-bar.is-ready) .algorithm-run-dot { + background: var(--success); +} + +.algorithm-run-text { + min-width: 0; +} + +.algorithm-run-error { + flex-basis: 100%; + color: #8a1f1f; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.html b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.html new file mode 100644 index 00000000..55a65a84 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.html @@ -0,0 +1,10 @@ + + + {{ run.readinessText() }} + +@if (!run.canRun()) { +Finish the needs above to enable the run. +} +@if (run.errorMsg()) { +Backend error: {{ run.errorMsg() }} +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.spec.ts new file mode 100644 index 00000000..506698d3 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.spec.ts @@ -0,0 +1,52 @@ +import { Component, provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { AlgorithmRunService } from './algorithm-run.service'; +import { ExperimentResultComponent } from './experiment-result.component'; + +@Component({ + imports: [ExperimentResultComponent], + template: '
    ', +}) +class HostComponent {} + +describe('ExperimentResultComponent', () => { + const run = { + canRun: signal(false), + readinessText: signal('Select a method'), + errorMsg: signal(null), + }; + + beforeEach(() => { + run.canRun.set(false); + run.errorMsg.set(null); + TestBed.configureTestingModule({ + imports: [HostComponent], + providers: [provideZonelessChangeDetection(), { provide: AlgorithmRunService, useValue: run }], + }); + }); + + function render(): HTMLElement { + const fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + return (fixture.nativeElement as HTMLElement).querySelector('.algorithm-run-bar') as HTMLElement; + } + + it('renders into the run bar and asks to finish the needs while the run is blocked', () => { + const bar = render(); + + expect(bar.getAttribute('role')).toBe('status'); + expect(bar.classList.contains('is-ready')).toBe(false); + expect(bar.querySelector('.algorithm-run-text')?.textContent).toContain('Select a method'); + expect(bar.querySelector('.algorithm-run-hint')).toBeTruthy(); + }); + + it('marks the bar ready and shows the last error', () => { + run.canRun.set(true); + run.errorMsg.set('Failed to export the result tables.'); + const bar = render(); + + expect(bar.classList.contains('is-ready')).toBe(true); + expect(bar.querySelector('.algorithm-run-hint')).toBeNull(); + expect(bar.querySelector('.algorithm-run-error')?.textContent).toContain('Backend error: Failed to export the result tables.'); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.ts b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.ts new file mode 100644 index 00000000..4a0ba1b9 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/experiment-result/experiment-result.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { AlgorithmRunService } from './algorithm-run.service'; + +/** + * The readiness bar at the bottom of the algorithm card: whether the run can start and why + * the last attempt or export failed. The run button itself is the studio's; the result shows + * in the Execution step. Attached to the bar element the panel projects into the card. + */ +@Component({ + selector: 'div[app-experiment-result]', + templateUrl: './experiment-result.component.html', + styleUrl: './experiment-result.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { '[class.is-ready]': 'run.canRun()' }, +}) +export class ExperimentResultComponent { + protected readonly run = inject(AlgorithmRunService); +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/styles/config-placeholder.shared.css b/src/app/pages/experiment-studio/algorithm-panel/styles/config-placeholder.shared.css new file mode 100644 index 00000000..ecf5c1ec --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/styles/config-placeholder.shared.css @@ -0,0 +1,41 @@ +/* Validation states: valid stays neutral; only the invalid edge and message signal a problem. */ +/* Placeholders */ +.config-placeholder { + width: 100%; + display: flex; + align-items: center; + gap: 16px; + padding: 14px 16px; + border-radius: var(--radius-md, 8px); + border: 1px solid rgba(226, 232, 240, 0.95); + background: rgba(248, 250, 252, 0.65); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85); +} + +.config-placeholder--minimal { + width: auto; + max-width: 100%; + min-height: 0; +} + +.config-placeholder-content { + display: flex; + flex-direction: column; + gap: 6px; + max-width: 560px; +} + +.config-placeholder-content h5, +.config-placeholder-content h6 { + margin: 0; + color: var(--text-main); + font-size: 0.92rem; + font-weight: 700; +} + +.config-placeholder-content p { + margin: 0; + color: var(--text-muted); + font-size: 0.82rem; + line-height: 1.6; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/styles/form-controls.shared.css b/src/app/pages/experiment-studio/algorithm-panel/styles/form-controls.shared.css new file mode 100644 index 00000000..19ef365f --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/styles/form-controls.shared.css @@ -0,0 +1,58 @@ +/* Inputs / selects */ +.config-input, +.config-select { + display: block; + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 9px 12px; + background: rgba(255, 255, 255, 0.98); + border-radius: var(--radius-md, 8px); + border: 1px solid rgba(148, 163, 184, 0.34); + font-size: 0.85rem; + line-height: 1.4; + transition: border-color 0.18s ease, box-shadow 0.18s ease, + background-color 0.18s ease; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.85) inset; +} + +.config-select { + padding-right: 30px; +} + +.config-select[multiple] { + min-height: 7rem; + padding-right: 12px; +} + +.config-input:focus, +.config-select:focus { + outline: none; + border-color: var(--primary-color); + background-color: var(--studio-card-bg); + box-shadow: + 0 0 0 3px rgba(127, 156, 232, 0.16), + 0 4px 12px rgba(43, 51, 233, 0.08); +} + +/* Checkbox wrapper */ +.checkbox-wrapper { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: var(--text-main); +} + +.checkbox-wrapper input[type="checkbox"] { + width: 18px; + height: 18px; + border-radius: 5px; +} + +/* Validation states */ +.config-error { + color: #b42318; + font-size: 0.74rem; + margin-top: 5px; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/styles/sr-only.shared.css b/src/app/pages/experiment-studio/algorithm-panel/styles/sr-only.shared.css new file mode 100644 index 00000000..5c260cfe --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/styles/sr-only.shared.css @@ -0,0 +1,7 @@ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/styles/step-kicker.shared.css b/src/app/pages/experiment-studio/algorithm-panel/styles/step-kicker.shared.css new file mode 100644 index 00000000..17c8525a --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/styles/step-kicker.shared.css @@ -0,0 +1,21 @@ +.step-kicker { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: var(--letter-spacing-config); + text-transform: uppercase; + color: var(--text-muted); +} + +.step-kicker-n { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + box-shadow: inset 0 0 0 1.5px var(--studio-card-border); + font-size: 0.68rem; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts new file mode 100644 index 00000000..6b957f21 --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.spec.ts @@ -0,0 +1,41 @@ +import { AlgorithmAvailabilityDetail } from '../../../../models/algorithm-definition.model'; +import { + formatAvailabilityMessage, + needsRequirementText, +} from './algorithm-availability-text.utils'; + +const detail = (overrides: Partial): AlgorithmAvailabilityDetail => ({ + role: 'y', + label: 'Outcome', + selectedCount: 0, + minCount: 1, + maxCount: 1, + required: true, + types: [], + stattypes: [], + messages: [], + satisfied: true, + ...overrides, +}); + +describe('algorithm availability text', () => { + it('words outcome needs with an article and the type family', () => { + expect(needsRequirementText(detail({ types: ['real', 'int'] }))).toBe('Needs a numerical outcome'); + expect(needsRequirementText(detail({ types: ['ordinal'] }))).toBe('Needs a categorical outcome'); + expect(needsRequirementText(detail({ types: ['other'] }))).toBe('Needs an other outcome'); + expect(needsRequirementText(detail({ minCount: 2 }))).toBe('Needs at least 2 outcomes'); + expect(needsRequirementText(detail({ minCount: 0, required: false }))).toBe('No outcome needed'); + }); + + it('words predictor needs by count', () => { + expect(needsRequirementText(detail({ role: 'x', minCount: 2, types: ['nominal'] }))).toBe('At least 2 categorical predictors'); + expect(needsRequirementText(detail({ role: 'x', minCount: 0, maxCount: 3 }))).toBe('Up to 3 predictor'); + expect(needsRequirementText(detail({ role: 'x', minCount: 0, maxCount: null }))).toBe('Predictors optional'); + }); + + it('rewrites backend type lists in availability messages', () => { + expect(formatAvailabilityMessage('Outcome type must be one of text, polynominal. Other text.')) + .toBe('Outcome type must be one of nominal. Other text.'); + expect(formatAvailabilityMessage('Outcome needs at least 2, selected 1.')).toBe('Outcome needs at least 2, selected 1.'); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts new file mode 100644 index 00000000..e811b42f --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-availability-text.utils.ts @@ -0,0 +1,86 @@ +import { VariableTypes } from '../../../../core/constants/algorithm.constants'; +import { AlgorithmAvailabilityDetail } from '../../../../models/algorithm-definition.model'; + +/** "numerical" / "categorical" wording for the needs list; unknown types pass through lowercased. */ +function needsTypeText(types: readonly string[] | undefined | null): string { + if (!Array.isArray(types) || types.length === 0) return ''; + const labels = new Set(); + types.forEach((type) => { + const normalized = String(type ?? '').trim().toLowerCase(); + if (!normalized) return; + if (['real', 'int', 'integer', 'numeric', 'numerical'].includes(normalized)) { + labels.add('numerical'); + } else if (['text', 'nominal', 'categorical', 'polynominal', 'ordinal', 'string'].includes(normalized)) { + labels.add('categorical'); + } else { + labels.add(normalized); + } + }); + return Array.from(labels).join(' or '); +} + +/** Title of one role line in the needs list, e.g. "Needs a numerical outcome" or "At least 2 predictors". */ +export function needsRequirementText(detail: AlgorithmAvailabilityDetail): string { + const typeText = needsTypeText(detail.types); + + if (detail.role === 'y') { + if (detail.minCount <= 0 && !detail.required) { + return 'No outcome needed'; + } + const article = typeText + ? `${/^[aeiou]/i.test(typeText) ? 'an' : 'a'} ${typeText} ` + : ''; + const noun = detail.minCount > 1 ? 'outcomes' : 'outcome'; + return detail.minCount > 1 + ? `Needs at least ${detail.minCount} ${noun}` + : `Needs ${article}${noun}`; + } + + const noun = detail.minCount > 1 ? 'predictors' : 'predictor'; + if (detail.minCount > 0) { + return `At least ${detail.minCount}${typeText ? ` ${typeText}` : ''} ${noun}`; + } + if (detail.maxCount !== null) { + return `Up to ${detail.maxCount} ${noun}`; + } + return 'Predictors optional'; +} + +/** Backend type names as the availability tips show them: text/polynominal/ordinal read "nominal", integer reads "int". */ +function requirementDisplayType(type: unknown): string | null { + const normalized = String(type ?? '').trim().toLowerCase(); + if (!normalized) return null; + + switch (normalized) { + case VariableTypes.TEXT: + case 'polynominal': + case 'ordinal': + return VariableTypes.NOMINAL; + case VariableTypes.INTEGER: + return VariableTypes.INT; + default: + return normalized; + } +} + +function requirementDisplayTypes(types: unknown): string[] | null { + if (!Array.isArray(types) || types.length === 0) { + return null; + } + const normalized = types + .map((type) => requirementDisplayType(type)) + .filter((type): type is string => !!type); + const unique = Array.from(new Set(normalized)); + return unique.length ? unique : null; +} + +/** Rewrites the type list of "Outcome/Predictor type must be one of …." with display type names. */ +export function formatAvailabilityMessage(message: string): string { + return message.replace( + /((?:Outcome|Predictor) type must be one of )([^.]*)\./g, + (_match, prefix: string, types: string) => { + const displayTypes = requirementDisplayTypes(types.split(',').map((type) => type.trim())); + return prefix + (displayTypes ?? []).join(', ') + '.'; + } + ); +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.spec.ts new file mode 100644 index 00000000..a7de214b --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.spec.ts @@ -0,0 +1,55 @@ +import { AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { countAlgorithms, filterAlgorithmCategories, nextCollapsedCategories, runnableCount } from './algorithm-catalog.utils'; + +const algo = (name: string, isDisabled = false, extra: Partial = {}): AlgorithmConfig => ({ + name, + label: name.replace(/_/g, ' '), + description: '', + requiredVariable: [], + covariate: [], + category: 'Test', + configSchema: [], + isDisabled, + ...extra, +}); + +describe('algorithm catalog utils', () => { + const grouped = { + Regression: [algo('logistic_regression', true), algo('linear_regression')], + Tests: [algo('ttest', true, { flags: ['paired'] })], + }; + + it('keeps runnable methods only, or all with runnable first', () => { + expect(filterAlgorithmCategories(grouped, true, '')).toEqual([{ name: 'Regression', algorithms: [grouped.Regression[1]] }]); + const all = filterAlgorithmCategories(grouped, false, ''); + expect(all.map((c) => c.algorithms.map((a) => a.name))).toEqual([['linear_regression', 'logistic_regression'], ['ttest']]); + }); + + it('matches every search term across label, name and flags', () => { + expect(filterAlgorithmCategories(grouped, false, ' PAIRED ').map((c) => c.name)).toEqual(['Tests']); + expect(filterAlgorithmCategories(grouped, false, 'linear regression').flatMap((c) => c.algorithms.map((a) => a.name))) + .toEqual(['linear_regression']); + expect(filterAlgorithmCategories(grouped, false, 'nothing')).toEqual([]); + }); + + it('counts all and runnable methods', () => { + expect(countAlgorithms(grouped)).toEqual({ all: 3, active: 1 }); + expect(countAlgorithms(null)).toEqual({ all: 0, active: 0 }); + expect(runnableCount({ name: 'Regression', algorithms: grouped.Regression })).toBe(1); + }); + + describe('nextCollapsedCategories', () => { + const categories = filterAlgorithmCategories(grouped, false, ''); + + it('collapses groups with nothing runnable and opens the rest', () => { + const next = nextCollapsedCategories(categories, null, true, new Set(), new Set()); + expect([...next]).toEqual(['Tests']); + }); + + it('reveals the group of a new selection but leaves user-toggled groups alone', () => { + expect([...nextCollapsedCategories(categories, 'ttest', true, new Set(['Tests']), new Set())]).toEqual([]); + const collapsed = new Set(['Regression']); + expect(nextCollapsedCategories(categories, null, false, collapsed, new Set(['Regression', 'Tests']))).toBe(collapsed); + }); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.ts new file mode 100644 index 00000000..7b3ff64e --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-catalog.utils.ts @@ -0,0 +1,93 @@ +import { AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { AlgorithmCategory } from '../algorithm-panel.models'; + +type GroupedAlgorithms = Record | null | undefined; + +/** Every whitespace-separated term must occur in label, name, description, documentation, category or flags. */ +function matchesAlgorithmSearch(algorithm: AlgorithmConfig, query: string): boolean { + if (!query) return true; + const haystack = [ + algorithm.label, + algorithm.name, + algorithm.description, + algorithm.documentation ?? '', + algorithm.category ?? '', + (algorithm.flags ?? []).join(' '), + ].join(' ').toLowerCase(); + return query.split(/\s+/).every((term) => haystack.includes(term)); +} + +/** + * The catalog groups to list: search-filtered, runnable only when `onlyActive`, and with + * runnable methods first when all are shown. Empty groups are dropped. + */ +export function filterAlgorithmCategories( + grouped: GroupedAlgorithms, + onlyActive: boolean, + rawQuery: string, +): AlgorithmCategory[] { + const query = rawQuery.trim().toLowerCase(); + const entries = Object.entries(grouped ?? {}).map(([name, algorithms]) => { + let filtered = (algorithms ?? []).filter((algorithm) => matchesAlgorithmSearch(algorithm, query)); + if (onlyActive) { + filtered = filtered.filter((algorithm) => !algorithm.isDisabled); + } + const ordered = onlyActive + ? filtered + : [...filtered].sort((a, b) => Number(a.isDisabled) - Number(b.isDisabled)); + return { name, algorithms: ordered }; + }); + return entries.filter((category) => category.algorithms.length > 0); +} + +/** All and runnable method counts across every group. */ +export function countAlgorithms(grouped: GroupedAlgorithms): { all: number; active: number } { + const all = Object.values(grouped ?? {}).flat(); + return { all: all.length, active: all.filter((algorithm) => !algorithm.isDisabled).length }; +} + +export function runnableCount(category: AlgorithmCategory): number { + return category.algorithms.filter((algorithm) => !algorithm.isDisabled).length; +} + +/** + * Next set of collapsed groups. By default a group is open only when it holds the selected + * method or at least one runnable method; on a new selection the selected method's group + * is revealed. Groups the user toggled are never changed. Returns `collapsed` itself when + * nothing changes. + */ +export function nextCollapsedCategories( + categories: readonly AlgorithmCategory[], + selectedName: string | null, + selectionChanged: boolean, + collapsed: ReadonlySet, + userToggled: ReadonlySet, +): ReadonlySet { + const selectedCategory = selectedName + ? categories.find((category) => + category.algorithms.some((algorithm) => algorithm.name === selectedName))?.name + : null; + + const next = new Set(collapsed); + let changed = false; + + categories.forEach((category) => { + if (userToggled.has(category.name)) return; + const shouldOpen = + category.name === selectedCategory || + category.algorithms.some((algorithm) => !algorithm.isDisabled); + if (shouldOpen === !next.has(category.name)) return; + if (shouldOpen) { + next.delete(category.name); + } else { + next.add(category.name); + } + changed = true; + }); + + if (selectedCategory && selectionChanged && next.delete(selectedCategory)) { + changed = true; + } + + return changed ? next : collapsed; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.spec.ts new file mode 100644 index 00000000..2568491f --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.spec.ts @@ -0,0 +1,130 @@ +import { AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { EnrichedParameterField } from '../algorithm-panel.models'; +import { + allowsDecimal, + buildParameterForm, + enrichParameterSchema, + enumOptionsForVariableCode, + isAdvancedField, + multiSelectValues, + nonBlankString, + normalizeParameterValues, + optionLabel, + parameterFieldLabel, + parameterFormSignature, +} from './algorithm-parameter-form.utils'; + +const algorithm = (name: string, configSchema: AlgorithmConfig['configSchema']): AlgorithmConfig => ({ + name, + label: name, + description: '', + requiredVariable: [], + covariate: [], + category: 'Test', + configSchema, + isDisabled: false, +}); + +const field = (overrides: Partial): EnrichedParameterField => ({ + key: 'k', + label: 'K', + desc: '', + ...overrides, +}); + +describe('algorithm parameter form utils', () => { + const sex = { code: 'sex', label: 'Sex', enumerations: [{ code: 'M', label: 'Male' }, { code: 'F', label: 'Female' }, { code: 'M', label: 'Dup' }] }; + const age = { code: 'age', label: 'Age' }; + + it('reads blank values as null and options by label, name, then the option itself', () => { + expect(nonBlankString(' ')).toBeNull(); + expect(nonBlankString(0)).toBe('0'); + expect(optionLabel({ code: 'M', label: 'Male' })).toBe('Male'); + expect(optionLabel({ code: 'M', name: 'Masc' })).toBe('Masc'); + expect(optionLabel('efron')).toBe('efron'); + expect(optionLabel(3)).toBe('3'); + }); + + it('treats real, alpha, tol and mu as decimal fields and defaulted optionals as advanced', () => { + expect(allowsDecimal({ key: 'alpha' })).toBe(true); + expect(allowsDecimal({ key: 'k', types: ['real'] })).toBe(true); + expect(allowsDecimal({ key: 'k', types: ['int'] })).toBe(false); + expect(isAdvancedField({ key: 'alpha', default: 0.05 })).toBe(true); + expect(isAdvancedField({ key: 'alpha', default: 0.05, required: true })).toBe(false); + expect(isAdvancedField({ key: 'k', advanced: false, default: 1 })).toBe(false); + }); + + it('names the covariate in group field labels', () => { + expect(parameterFieldLabel(field({ key: 'groupA', label: 'Group A' }), sex)).toBe('Group A (Sex)'); + expect(parameterFieldLabel(field({ key: 'alpha', label: 'Alpha' }), sex)).toBe('Alpha'); + }); + + it('lists distinct enumeration codes of a selected variable', () => { + expect(enumOptionsForVariableCode('sex', [age, sex])).toEqual([{ code: 'M', label: 'Male' }, { code: 'F', label: 'Female' }]); + expect(enumOptionsForVariableCode('missing', [age, sex])).toEqual([]); + }); + + it('resolves options from the selected variables and fills labels', () => { + const schema = enrichParameterSchema( + algorithm('cox_regression_classical', [ + { key: 'event_var', type: 'select', enumType: 'input_var_names', enumSource: ['x'], required: 'true' }, + { key: 'positive_class', type: 'text' }, + { key: 'levels', type: 'select', options: ['y'] }, + { key: 'max_iter', label: ' ', type: 'number', default: 100 }, + ]), + { y: [sex], x: [age, sex], eventVarCode: 'sex', withCrossValidationFolds: true }, + ); + const byKey = Object.fromEntries(schema.map((f) => [f.key, f])); + expect(byKey['event_var'].options).toEqual([{ code: 'age', label: 'Age' }, { code: 'sex', label: 'Sex' }]); + expect(byKey['event_var'].required).toBe(true); + expect(byKey['positive_class']).toEqual(expect.objectContaining({ + type: 'select', + required: true, + desc: 'Event level mapped to 1; other observed levels are mapped to 0.', + })); + expect(byKey['levels'].options).toEqual(sex.enumerations); + expect(byKey['max_iter'].label).toBe('Max Iter'); + expect(byKey['n_splits']).toEqual(expect.objectContaining({ label: 'Folds', type: 'number', min: 2, default: 5 })); + }); + + it('defaults category_order to every outcome level', () => { + const [order] = enrichParameterSchema(algorithm('ordinal', [{ key: 'category_order', type: 'multi-select' }]), { + y: [sex], x: [], eventVarCode: null, withCrossValidationFolds: false, + }); + expect(order.default).toEqual(['M', 'F', 'M']); + }); + + it('changes the form signature with cross-validation and options only', () => { + const schema = [field({ key: 'a', type: 'select', options: [{ code: 'x' }] })]; + expect(parameterFormSignature('m', false, schema)).toBe(parameterFormSignature('m', false, [{ ...schema[0], label: 'Other' }])); + expect(parameterFormSignature('m', true, schema)).not.toBe(parameterFormSignature('m', false, schema)); + }); + + it('starts controls from stored, backend, then UI defaults and validates integers', () => { + const form = buildParameterForm([ + field({ key: 'alpha', type: 'number' }), + field({ key: 'k', type: 'number', types: ['int'] }), + field({ key: 'flag', type: 'checkbox' }), + field({ key: 'name', type: 'text', default: 'x' }), + ], { k: '2.5' }); + expect(form.getRawValue()).toEqual({ alpha: 0.05, k: 2.5, flag: false, name: 'x' }); + expect(form.get('k')?.hasError('pattern')).toBe(true); + expect(form.get('alpha')?.touched).toBe(true); + expect(form.get('k')?.touched).toBe(false); + }); + + it('parses numbers, serializes enum codes and drops empty optionals', () => { + const schema = [ + field({ key: 'n', type: 'number' }), + field({ key: 'level', type: 'select', options: [{ code: 'M', label: 'Male' }] }), + field({ key: 'note', type: 'text' }), + ]; + expect(normalizeParameterValues({ n: ' 3 ', level: 'Male', note: '' }, schema)).toEqual({ n: 3, level: 'M' }); + }); + + it('reads multi-select values from arrays and comma lists', () => { + expect(multiSelectValues(['a', 1])).toEqual(['a', '1']); + expect(multiSelectValues('a, b,,')).toEqual(['a', 'b']); + expect(multiSelectValues(null)).toEqual([]); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.ts new file mode 100644 index 00000000..1b92847c --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-parameter-form.utils.ts @@ -0,0 +1,286 @@ +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { prettifyLabel as toHumanLabel } from '../../../../core/algorithm-mappers'; +import { + omitEmptyOptionalParameters, + serializeAlgorithmParameterValue, +} from '../../../../core/algorithm-parameter.utils'; +import { AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { buildFormControl } from '../../../shared/utils/form-control.factory'; +import { + AlgorithmParameterField, + CodeLabelOption, + EnrichedParameterField, + ParameterOption, + StudioVariable, +} from '../algorithm-panel.models'; + +/** Parameter values the form starts from when neither the stored config nor the backend has one. */ +const UI_DEFAULTS: Readonly> = { + alpha: 0.05, + alt_hypothesis: 'two-sided', + mu: 0.0, + n_splits: 5, + k: 4, + tol: 0.01, + sstype: 2, + conf_level: 0.95, + iterations: 1000, +}; + +/** Number fields that take decimals although the backend does not type them `real`. */ +const DECIMAL_KEYS = new Set(['alpha', 'tol', 'mu']); + +/** `String(raw)` when `raw` holds a non-blank value, else null. */ +export function nonBlankString(raw: unknown): string | null { + return raw !== null && raw !== undefined && String(raw).trim() !== '' ? String(raw) : null; +} + +/** The primitive an option stands for: code, value, name or label of an object option. */ +function optionValue(option: ParameterOption | null | undefined): unknown { + if (option && typeof option === 'object') { + return option.code ?? option.value ?? option.name ?? option.label ?? String(option); + } + return option; +} + +/** What an option shows: label, then name, then the option itself. */ +export function optionLabel(option: ParameterOption | null | undefined): string { + const shown = option && typeof option === 'object' ? option.label ?? option.name ?? option : option; + return shown === null || shown === undefined ? '' : String(shown); +} + +/** Whether a number field accepts decimals (else it is integer-only and steps by 1). */ +export function allowsDecimal(field: Pick): boolean { + return !!field.types?.includes('real') || DECIMAL_KEYS.has(field.key); +} + +/** Backend metadata has no advanced flag yet; the heuristic mirrors the review handoff. */ +export function isAdvancedField(field: AlgorithmParameterField): boolean { + return field.advanced ?? (field.default !== undefined && !field.required); +} + +export function isCoxRegressionAlgorithm(algorithmName: string | null | undefined): boolean { + return algorithmName === 'cox_regression_classical' || algorithmName === 'cox_regression_stacked'; +} + +/** Label of a group field (`groupA` / `groupB`) names the covariate the groups come from. */ +export function parameterFieldLabel(field: EnrichedParameterField, firstCovariate: StudioVariable | undefined): string { + let base = field.label ?? field.key; + if (field.key === 'groupA' || field.key === 'groupB') { + const covName = firstCovariate?.label || firstCovariate?.name || firstCovariate?.code; + if (covName) base = `${base} (${covName})`; + } + return base; +} + +/** The selected y / x variables as options, for `input_var_names` parameters (x when no role is named). */ +function variableOptionsForRoles(roles: readonly string[], y: readonly StudioVariable[], x: readonly StudioVariable[]): CodeLabelOption[] { + const roleList = roles.length ? roles : ['x']; + return roleList.flatMap((role) => { + const selected = role === 'y' ? y : role === 'x' ? x : []; + return selected + .map((item) => { + const code = item?.code; + if (code === null || code === undefined || code === '') return null; + return { code: String(code), label: String(item?.label ?? item?.name ?? code) }; + }) + .filter((item): item is CodeLabelOption => !!item); + }); +} + +/** Enumerations of the first variable in `role` (x for "x", y otherwise). */ +function enumOptionsForRole(role: string | null | undefined, y: readonly StudioVariable[], x: readonly StudioVariable[]): ParameterOption[] { + const selected = role === 'x' ? x[0] : y[0]; + return Array.isArray(selected?.enumerations) ? [...selected.enumerations] : []; +} + +/** Distinct enumeration codes of the variable `varCode` among the selected variables. */ +export function enumOptionsForVariableCode(varCode: string | null | undefined, variables: readonly StudioVariable[]): CodeLabelOption[] { + if (!varCode) return []; + const variable = variables.find((item) => String(item?.code) === String(varCode)); + if (!variable) return []; + + const enumerations = Array.isArray(variable.enumerations) ? variable.enumerations : []; + const seen = new Set(); + return enumerations + .map((entry): CodeLabelOption | null => { + const record = entry && typeof entry === 'object' ? entry : null; + const code = nonBlankString(record?.code ?? record?.value ?? record?.name ?? record?.label); + if (code === null || seen.has(code)) return null; + seen.add(code); + return { code, label: String(record?.label ?? record?.name ?? code) }; + }) + .filter((item): item is CodeLabelOption => !!item); +} + +/** Selection context the parameter schema is enriched with. */ +export interface ParameterSchemaContext { + y: readonly StudioVariable[]; + x: readonly StudioVariable[]; + /** Live or stored `event_var` (Cox), which decides the `positive_class` options. */ + eventVarCode: string | null; + /** Cross-validation is on and the method can toggle it: adds a Folds field when the schema has none. */ + withCrossValidationFolds: boolean; +} + +/** + * The algorithm's parameters as the form shows them: options from the selected variables + * (`input_var_names`, `input_var_CDE_enums`, `category_order`, `positive_class`, y/x + * placeholders, the y enumerations as a fallback for empty selects), a human label, a + * description, and the Cox `positive_class` rules. + */ +export function enrichParameterSchema(algorithm: AlgorithmConfig, context: ParameterSchemaContext): EnrichedParameterField[] { + const schema: AlgorithmParameterField[] = (algorithm.configSchema ?? []).map((field) => ({ ...field })); + const { y, x } = context; + const yVar = y[0]; + const xVar = x[0]; + const positiveClassOptions = enumOptionsForVariableCode(context.eventVarCode, [...y, ...x]); + const isCox = isCoxRegressionAlgorithm(algorithm.name); + + const enriched = schema.map((field): EnrichedParameterField => { + let options: ParameterOption[] = field.options ?? []; + const enumSource = Array.isArray(field.enumSource) ? field.enumSource : []; + + if (field.enumType === 'input_var_names') { + options = variableOptionsForRoles(enumSource, y, x); + } else if (field.enumType === 'input_var_CDE_enums') { + options = enumOptionsForRole(enumSource[0], y, x); + } else if (field.key === 'category_order' && yVar?.enumerations?.length) { + options = [...yVar.enumerations]; + } else if (field.key === 'positive_class' && positiveClassOptions.length) { + options = positiveClassOptions; + } + + // Placeholder substitution for enums + if (Array.isArray(options) && options.length === 1) { + const placeholder = options[0]; + if (placeholder === 'y' && yVar?.enumerations?.length) { + options = [...yVar.enumerations]; + } else if (placeholder === 'x' && xVar?.enumerations?.length) { + options = [...xVar.enumerations]; + } + } + + // Fallback for empty select fields (not input_var_names — those list y/x variables) + if ( + field.type === 'select' && + field.enumType !== 'input_var_names' && + (!options || options.length === 0) && + yVar?.enumerations?.length + ) { + options = [...yVar.enumerations]; + } + + const defaultValue = field.key === 'category_order' && Array.isArray(options) && options.length > 0 + ? options.map((option) => optionValue(option)) + : field.default; + const label = field.label?.trim() || toHumanLabel(field.key); + const desc = + field.key === 'positive_class' && isCox + ? 'Event level mapped to 1; other observed levels are mapped to 0.' + : field.desc ?? field.description ?? ''; + const type = field.key === 'positive_class' && positiveClassOptions.length ? 'select' : field.type; + const required = + (field.key === 'positive_class' && isCox) || + field.required === true || + String(field.required ?? '').trim().toLowerCase() === 'true'; + + return { ...field, label, desc, options, default: defaultValue, type, required }; + }); + + if (context.withCrossValidationFolds && !enriched.some((field) => field.key === 'n_splits')) { + enriched.push({ + key: 'n_splits', + label: 'Folds', + desc: 'Number of parts the data is split into; each part is held out once.', + type: 'number', + min: 2, + default: 5, + }); + } + + return enriched; +} + +/** Identity of a built form: the form is rebuilt only when this changes. */ +export function parameterFormSignature(algorithmName: string, crossValidation: boolean, schema: readonly AlgorithmParameterField[]): string { + return JSON.stringify({ + algorithmName, + crossValidation, + fields: schema.map((field) => ({ + key: field.key, + type: field.type, + required: field.required, + options: (field.options ?? []).map((option) => optionValue(option)), + })), + }); +} + +/** + * The parameter form: one control per field, starting from the stored value, then the + * backend default, then {@link UI_DEFAULTS}. Integer-only number fields get a pattern + * validator. Controls that start valid are marked touched. + */ +export function buildParameterForm(schema: readonly EnrichedParameterField[], stored: Record): FormGroup { + const group: Record = {}; + + schema.forEach((field) => { + const storedValue = stored?.[field.key]; + const initialValue = storedValue !== undefined + ? storedValue + : field.default ?? UI_DEFAULTS[field.key] ?? (field.type === 'checkbox' ? false : ''); + const control = buildFormControl(field, initialValue); + + if (field.type === 'number' && !allowsDecimal(field)) { + const integerPattern = Validators.pattern(/^-?\d+$/); + const existingValidator = control.validator; + control.setValidators(existingValidator ? [existingValidator, integerPattern] : [integerPattern]); + control.updateValueAndValidity({ emitEvent: false }); + } + + group[field.key] = control; + }); + + const form = new FormGroup(group, { updateOn: 'change' }); + Object.values(form.controls).forEach((control) => { + if (control.valid) { + control.markAsTouched({ onlySelf: true }); + } + }); + return form; +} + +/** Form values as the request sends them: numbers parsed, enum values as codes, empty optionals dropped. */ +export function normalizeParameterValues(values: Record, schema: readonly EnrichedParameterField[]): Record { + const normalized = { ...values }; + schema + .filter((field) => field?.type === 'number') + .forEach((field) => { + const key = String(field.key); + const value = normalized[key]; + if (typeof value !== 'string') return; + const trimmed = value.trim(); + if (!trimmed) return; + const parsed = Number(trimmed); + if (Number.isFinite(parsed)) normalized[key] = parsed; + }); + + schema.forEach((field) => { + const key = String(field.key); + if (!(key in normalized)) return; + if (field?.type === 'select' || field?.type === 'multi-select' || key === 'positive_class') { + normalized[key] = serializeAlgorithmParameterValue(normalized[key], field); + } + }); + + return omitEmptyOptionalParameters(normalized, [...schema]); +} + +/** A multi-select control value as a list of strings (arrays as is, comma-separated strings split). */ +export function multiSelectValues(value: unknown): string[] { + if (Array.isArray(value)) return value.map((entry) => String(entry)); + if (typeof value === 'string' && value.trim() !== '') { + return value.split(',').map((entry) => entry.trim()).filter((entry) => entry !== ''); + } + return []; +} diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.spec.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.spec.ts new file mode 100644 index 00000000..73fa041f --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.spec.ts @@ -0,0 +1,73 @@ +import { AlgorithmAvailabilityDetail, AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { buildAlgorithmNeeds, readinessText, resolveRunAlgorithmNames } from './algorithm-run.utils'; + +const algorithm = (extra: Partial = {}): AlgorithmConfig => ({ + name: 'linear_regression', + label: 'Linear regression', + description: '', + requiredVariable: [], + covariate: [], + category: 'Regression', + configSchema: [], + isDisabled: false, + ...extra, +}); + +const outcomeDetail: AlgorithmAvailabilityDetail = { + role: 'y', label: 'Outcome', selectedCount: 0, minCount: 1, maxCount: 1, required: true, + types: ['real'], stattypes: [], messages: ['Outcome needs exactly 1, selected 0.'], satisfied: false, +}; + +describe('algorithm run utils', () => { + it('lists role needs, unavailable variables and run blockers in order', () => { + const needs = buildAlgorithmNeeds({ + algorithm: algorithm(), + details: [outcomeDetail], + hasOutcome: false, + unavailableVariablesReason: 'Remove variables not available in: ADNI', + requirements: [{ id: 'preprocessing', message: 'Apply missing value preprocessing before running algorithms.', actionLabel: 'Go to preprocessing' }], + }); + expect(needs.map((need) => [need.key, need.met, need.title])).toEqual([ + ['needs-y', false, 'Needs a numerical outcome'], + ['unavailable-variables', false, 'Remove variables not available in: ADNI'], + ['run-preprocessing', false, 'Apply missing value preprocessing before running algorithms.'], + ]); + }); + + it('says an outcome is ignored by methods without one', () => { + const [need] = buildAlgorithmNeeds({ + algorithm: algorithm({ inputdata: {} as AlgorithmConfig['inputdata'] }), + details: [], hasOutcome: true, unavailableVariablesReason: '', requirements: [], + }); + expect(need).toEqual({ key: 'needs-y', met: true, title: 'No outcome needed', messages: ['Your outcome is ignored'] }); + }); + + it('summarizes readiness', () => { + expect(readinessText({ algorithm: null, canRun: false, needs: [], y: [], x: [] })).toBe('Pick a method to continue'); + expect(readinessText({ algorithm: algorithm(), canRun: false, needs: [], y: [], x: [] })).toBe('Not ready to run yet'); + const needs = [ + { key: 'a', met: false, title: 'A', messages: ['one', 'two'] }, + { key: 'b', met: false, title: 'Fix B', messages: [], action: { id: 'availability' as const, message: 'Fix B', actionLabel: 'Go' } }, + ]; + expect(readinessText({ algorithm: algorithm(), canRun: false, needs, y: [], x: [] })).toBe('3 things left — one · two +1'); + expect(readinessText({ algorithm: algorithm(), canRun: true, needs: [], y: [{ code: 'age', label: 'Age' }], x: [{ code: 'sex' }] })) + .toBe('Ready · Linear regression · y Age · 1 predictor'); + }); + + it('resolves base, variant and sent names for cross-validation', () => { + const catalog = { + isCrossValidationOnly: (name: string) => name === 'svm_cv', + getCrossValidationBase: (name: string) => (name.endsWith('_cv') ? name.slice(0, -3) : null), + getCrossValidationVariant: (base: string) => (base === 'linear_regression' ? 'linear_regression_cv' : null), + }; + expect(resolveRunAlgorithmNames('linear_regression', false, catalog)).toEqual({ + baseName: 'linear_regression', effectiveName: 'linear_regression', finalName: 'linear_regression', includeSplits: false, + }); + expect(resolveRunAlgorithmNames('linear_regression', true, catalog)).toEqual({ + baseName: 'linear_regression', effectiveName: 'linear_regression_cv', finalName: 'linear_regression_cv', includeSplits: true, + }); + expect(resolveRunAlgorithmNames('svm_cv', false, catalog)).toEqual({ + baseName: 'svm_cv', effectiveName: 'svm_cv', finalName: 'svm_cv', includeSplits: true, + }); + }); +}); diff --git a/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.ts b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.ts new file mode 100644 index 00000000..7f4ca29c --- /dev/null +++ b/src/app/pages/experiment-studio/algorithm-panel/utils/algorithm-run.utils.ts @@ -0,0 +1,109 @@ +import { AlgorithmAvailabilityDetail, AlgorithmConfig } from '../../../../models/algorithm-definition.model'; +import { AlgorithmNeedItem, AlgorithmRunRequirement, StudioVariable } from '../algorithm-panel.models'; +import { needsRequirementText } from './algorithm-availability-text.utils'; + +/** What the needs list is built from. */ +interface AlgorithmNeedsInput { + algorithm: AlgorithmConfig; + details: readonly AlgorithmAvailabilityDetail[]; + /** An outcome is assigned (it is ignored by methods without one). */ + hasOutcome: boolean; + /** "Remove variables not available in: …", or ''. */ + unavailableVariablesReason: string; + requirements: readonly AlgorithmRunRequirement[]; +} + +/** The needs list: one line per role (outcome, then predictors), unavailable variables, then run blockers with actions. */ +export function buildAlgorithmNeeds(input: AlgorithmNeedsInput): AlgorithmNeedItem[] { + const needs: AlgorithmNeedItem[] = []; + const detailByRole = new Map(input.details.map((detail) => [detail.role, detail])); + + for (const role of ['y', 'x'] as const) { + const detail = detailByRole.get(role); + if (detail) { + needs.push({ + key: `needs-${role}`, + met: detail.messages.length === 0, + title: needsRequirementText(detail), + messages: [...detail.messages], + }); + } else if (role === 'y' && !('y' in (input.algorithm.inputdata ?? {}))) { + needs.push({ + key: 'needs-y', + met: true, + title: 'No outcome needed', + messages: input.hasOutcome ? ['Your outcome is ignored'] : [], + }); + } + } + + if (input.unavailableVariablesReason) { + needs.push({ key: 'unavailable-variables', met: false, title: input.unavailableVariablesReason, messages: [] }); + } + + input.requirements.forEach((requirement) => { + needs.push({ key: `run-${requirement.id}`, met: false, title: requirement.message, messages: [], action: requirement }); + }); + + return needs; +} + +/** What the run bar's status line is built from. */ +interface ReadinessInput { + algorithm: AlgorithmConfig | null; + canRun: boolean; + needs: readonly AlgorithmNeedItem[]; + y: readonly StudioVariable[]; + x: readonly StudioVariable[]; +} + +/** "Pick a method to continue", "2 things left — …", or "Ready · Method · y Outcome · 2 predictors". */ +export function readinessText({ algorithm, canRun, needs, y, x }: ReadinessInput): string { + if (!algorithm) return 'Pick a method to continue'; + + if (!canRun) { + const reasons = needs + .filter((need) => !need.met) + .flatMap((need) => (need.action ? [need.title] : need.messages)) + .filter((message) => !!message); + if (!reasons.length) return 'Not ready to run yet'; + return `${reasons.length} ${reasons.length === 1 ? 'thing' : 'things'} left — ${reasons.slice(0, 2).join(' · ')}${reasons.length > 2 ? ` +${reasons.length - 2}` : ''}`; + } + + const outcome = y[0] ? ` · y ${y[0].label ?? y[0].name ?? y[0].code}` : ''; + const predictors = ` · ${x.length} ${x.length === 1 ? 'predictor' : 'predictors'}`; + return `Ready · ${algorithm.label}${outcome}${predictors}`; +} + +/** Cross-validation lookups of ExperimentStudioService the run naming needs. */ +export interface CrossValidationCatalog { + isCrossValidationOnly(name: string): boolean; + getCrossValidationBase(name: string): string | null; + getCrossValidationVariant(baseName: string): string | null; +} + +/** Which algorithm names a run uses. */ +export interface RunAlgorithmNames { + /** The base method; its config is stored under this name. */ + baseName: string; + /** The cross-validation variant when it runs, else the base. */ + effectiveName: string; + /** The name sent and recorded as the run's algorithm. */ + finalName: string; + /** Whether `n_splits` stays in the parameters. */ + includeSplits: boolean; +} + +/** Resolves base, variant and sent names for running `algorithmName` with cross-validation on or off. */ +export function resolveRunAlgorithmNames( + algorithmName: string, + crossValidationEnabled: boolean, + catalog: CrossValidationCatalog, +): RunAlgorithmNames { + const isCvOnly = catalog.isCrossValidationOnly(algorithmName); + const baseName = isCvOnly ? algorithmName : catalog.getCrossValidationBase(algorithmName) ?? algorithmName; + const variant = catalog.getCrossValidationVariant(baseName); + const includeSplits = crossValidationEnabled || isCvOnly; + const effectiveName = includeSplits && variant ? variant : baseName; + return { baseName, effectiveName, finalName: isCvOnly ? algorithmName : effectiveName, includeSplits }; +}