Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/components/map-projects/Candidates.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import AICandidatesAnalysis from './AICandidatesAnalysis'
import AIAssistantButton from './AIAssistantButton'
import {
buildAlgorithmRowViews,
getOrphanAlgorithmIds,
buildQualityRowViews,
conceptBelongsToTargetRepo,
sortRowViews,
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions src/components/map-projects/__tests__/views.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import assert from 'node:assert/strict'

import {
buildAlgorithmRowViews,
getOrphanAlgorithmIds,
buildQualityRowViews,
candidateToRowView,
conceptForMapping,
Expand Down Expand Up @@ -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', () => {
Expand Down
19 changes: 19 additions & 0 deletions src/components/map-projects/viewBuilders.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/es/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh/translations.json
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@
"ciel_bridge_terminology_candidates": "CIEL 桥接术语候选对象",
"fetching": "正在获取...",
"fetch_more": "获取更多",
"unrecognized_algorithm": "无法识别的算法",
"source_code": "源代码",
"class": "类",
"datatype": "数据类型",
Expand Down