From 6c3a9157c674d15db361a1205db408c15a6825c4 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 2 Jun 2026 13:55:32 -0400 Subject: [PATCH] OpenConceptLab/ocl_issues#2556 | show orphan-algorithm candidates in by-algorithm view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The by-algorithm grouping of the Candidates tab only iterated the project's configured algorithms[], so candidates whose algorithm_id is not in algosSelected (project reconfigured after running algorithms, or PR3-C v1->v2 migration tags) appeared in no bucket — even though they already render in the quality view and the AI payload, both gated by target-repo membership rather than algorithm_id. Add getOrphanAlgorithmIds(rowState, configuredIds) to viewBuilders (pure, unit-tested) and, in getCandidates()'s algorithm branch, append one bucket per orphan id after the configured algos using the existing buildAlgorithmRowViews. Buckets use a synthesized algo {id, name, order: Infinity} labeled 'Unrecognized algorithm ()' and keep the by-algo full mix (no target-repo filter; per-row mappability stays gated downstream). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/map-projects/Candidates.jsx | 17 +++++- .../map-projects/__tests__/views.test.js | 61 +++++++++++++++++++ src/components/map-projects/viewBuilders.js | 19 ++++++ src/i18n/locales/en/translations.json | 1 + src/i18n/locales/es/translations.json | 1 + src/i18n/locales/zh/translations.json | 1 + 6 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/components/map-projects/Candidates.jsx b/src/components/map-projects/Candidates.jsx index 3322bd5..a6050e1 100644 --- a/src/components/map-projects/Candidates.jsx +++ b/src/components/map-projects/Candidates.jsx @@ -49,6 +49,7 @@ import AICandidatesAnalysis from './AICandidatesAnalysis' import AIAssistantButton from './AIAssistantButton' import { buildAlgorithmRowViews, + getOrphanAlgorithmIds, buildQualityRowViews, conceptBelongsToTargetRepo, sortRowViews, @@ -530,8 +531,22 @@ const Candidates = ({rowIndex, alert, setAlert, rowState, conceptCache, targetCa ['hasCandidates', 'algo.order'], ['desc', 'asc'] ) + // A candidate's algorithm_id can fall outside the project's configured + // algorithms[] — the project was reconfigured after running algorithms, + // or the PR3-C v1->v2 migration tagged it with an id that isn't current. + // These "orphan-algo" candidates already show in the quality view and the + // AI payload (both gated by target-repo membership, never by + // algorithm_id), but the by-algorithm grouping above only iterates the + // configured algos — so they'd appear in no bucket here. Surface one + // bucket per orphan id, after all configured algos. Like the configured + // buckets, these keep the by-algo "full mix" (no target-repo filter); + // per-row mappability stays gated downstream as before. + const orphanAlgos = getOrphanAlgorithmIds(rowState, algosSelected.map(a => a.id)).map(id => ({ + algo: { id, name: t('map_project.unrecognized_algorithm'), order: Infinity }, + views: buildAlgorithmRowViews(rowState, conceptCache, id) + })) return { - byAlgoCandidates: sortedAlgos.map(({algo, views}) => ({ + byAlgoCandidates: [...sortedAlgos, ...orphanAlgos].map(({algo, views}) => ({ algo, // Sort the top-level (bridge intermediaries and standard candidates) // and, for each bridge, sort its nested cascade targets by the same diff --git a/src/components/map-projects/__tests__/views.test.js b/src/components/map-projects/__tests__/views.test.js index 4adc8d3..8fa6896 100644 --- a/src/components/map-projects/__tests__/views.test.js +++ b/src/components/map-projects/__tests__/views.test.js @@ -17,6 +17,7 @@ import assert from 'node:assert/strict' import { buildAlgorithmRowViews, + getOrphanAlgorithmIds, buildQualityRowViews, candidateToRowView, conceptForMapping, @@ -212,6 +213,66 @@ test('buildAlgorithmRowViews skips candidates whose ConceptDefinition is missing assert.deepEqual(buildAlgorithmRowViews(rowState, {}, 'ocl-search'), []) }) +// ---------- getOrphanAlgorithmIds ---------- + +test('getOrphanAlgorithmIds returns empty array when rowState is null', () => { + assert.deepEqual(getOrphanAlgorithmIds(null, ['ocl-search']), []) +}) + +test('getOrphanAlgorithmIds returns distinct candidate algorithm_ids not in the configured set', () => { + const rowState = { + candidates: { + c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard' }, + c2: { id: 'c2', algorithm_id: 'legacy-fuzzy', concept_key: KEY_LOINC_CHOL, type: 'standard' }, + c3: { id: 'c3', algorithm_id: 'legacy-fuzzy', concept_key: KEY_LOINC_GLUCOSE, type: 'standard' }, + c4: { id: 'c4', algorithm_id: 'ocl-bridge', concept_key: KEY_CIEL_BRIDGE, type: 'bridge' } + }, + concept_rows: {} + } + // ocl-search & ocl-bridge are configured; legacy-fuzzy is the only orphan, + // de-duplicated across c2/c3. + assert.deepEqual(getOrphanAlgorithmIds(rowState, ['ocl-search', 'ocl-bridge']), ['legacy-fuzzy']) +}) + +test('getOrphanAlgorithmIds returns [] when every candidate algorithm_id is configured', () => { + const rowState = { + candidates: { + c1: { id: 'c1', algorithm_id: 'ocl-search', concept_key: KEY_LOINC_GLUCOSE, type: 'standard' } + }, + concept_rows: {} + } + assert.deepEqual(getOrphanAlgorithmIds(rowState, ['ocl-search']), []) +}) + +test('getOrphanAlgorithmIds ignores candidates with a null/undefined algorithm_id', () => { + const rowState = { + candidates: { + c1: { id: 'c1', algorithm_id: null, concept_key: KEY_LOINC_GLUCOSE, type: 'standard' }, + c2: { id: 'c2', concept_key: KEY_LOINC_CHOL, type: 'standard' }, + c3: { id: 'c3', algorithm_id: 'orphan-x', concept_key: KEY_LOINC_GLUCOSE, type: 'standard' } + }, + concept_rows: {} + } + assert.deepEqual(getOrphanAlgorithmIds(rowState, ['ocl-search']), ['orphan-x']) +}) + +test('getOrphanAlgorithmIds orphan id round-trips through buildAlgorithmRowViews into a renderable bucket', () => { + // The component appends a bucket per orphan id using buildAlgorithmRowViews + // with that id. Assert the full-mix bucket includes a non-target candidate. + const cache = { [KEY_LOINC_GLUCOSE]: defLOINCGlucose } + const rowState = { + candidates: { + c1: { id: 'c1', algorithm_id: 'orphan-x', concept_key: KEY_LOINC_GLUCOSE, type: 'standard', score: 0.7 } + }, + concept_rows: {} + } + const [orphanId] = getOrphanAlgorithmIds(rowState, ['ocl-search']) + assert.equal(orphanId, 'orphan-x') + const views = buildAlgorithmRowViews(rowState, cache, orphanId) + assert.equal(views.length, 1) + assert.equal(views[0].candidate.id, 'c1') +}) + // ---------- buildQualityRowViews ---------- test('buildQualityRowViews returns empty array when rowState is null', () => { diff --git a/src/components/map-projects/viewBuilders.js b/src/components/map-projects/viewBuilders.js index 886f676..9f91938 100644 --- a/src/components/map-projects/viewBuilders.js +++ b/src/components/map-projects/viewBuilders.js @@ -112,6 +112,25 @@ export const buildAlgorithmRowViews = (rowState, conceptCache, algoId) => { })) } +/** + * The distinct algorithm_ids present in rowState.candidates that are NOT in + * the project's configured algorithm set. These "orphan-algo" candidates are + * legitimate (a candidate's algorithm_id is a benign label, never a filter — + * membership is gated by target-repo elsewhere) and arise whenever a project + * is reconfigured after running algorithms, or via the PR3-C v1->v2 migration. + * The by-algorithm grouping iterates only configured algos, so these ids would + * otherwise have no bucket. Order is stable (first-seen) for deterministic + * rendering. configuredIds may be any iterable of algorithm ids. + */ +export const getOrphanAlgorithmIds = (rowState, configuredIds) => { + if(!rowState) return [] + const configured = new Set(configuredIds || []) + const present = values(rowState.candidates || {}) + .map(c => c.algorithm_id) + .filter(id => id != null && !configured.has(id)) + return uniq(present) +} + /** * Quality-grouped view: iterate `RowState.concept_rows`. One entry per * concept_key (the per-row presence of a concept). Each entry exposes diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 2a6918d..5a09fb5 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -523,6 +523,7 @@ "ciel_bridge_terminology_candidates": "CIEL Bridge Terminology Candidates", "fetching": "Fetching...", "fetch_more": "Fetch More", + "unrecognized_algorithm": "Unrecognized algorithm", "source_code": "Source Code", "class": "Class", "datatype": "Datatype", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index edb28fb..2c791c7 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -494,6 +494,7 @@ "ciel_bridge_terminology_candidates": "Candidatos de Terminología Puente CIEL", "fetching": "Obteniendo...", "fetch_more": "Obtener Más", + "unrecognized_algorithm": "Algoritmo no reconocido", "source_code": "Código Fuente", "class": "Clase", "datatype": "Tipo de Dato", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index b0757a9..44a9b8e 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -519,6 +519,7 @@ "ciel_bridge_terminology_candidates": "CIEL 桥接术语候选对象", "fetching": "正在获取...", "fetch_more": "获取更多", + "unrecognized_algorithm": "无法识别的算法", "source_code": "源代码", "class": "类", "datatype": "数据类型",