diff --git a/DashAI/back/dependencies/registry/component_registry.py b/DashAI/back/dependencies/registry/component_registry.py index d99145aaf..98da941f7 100644 --- a/DashAI/back/dependencies/registry/component_registry.py +++ b/DashAI/back/dependencies/registry/component_registry.py @@ -167,6 +167,33 @@ def _get_base_type(self, new_component: type) -> str: return base_classes_cantidates[0].TYPE + @staticmethod + @beartype + def _collect_compatible_components(component: type) -> List[str]: + """Collect the union of ``COMPATIBLE_COMPONENTS`` declared along the MRO. + + Each class in the component's MRO contributes only the entries it + declares itself, so mixins and base classes compose instead of the + first declaration shadowing the rest (e.g. a task mixin declaring the + task plus a model base class declaring its supported explainers). + + Parameters + ---------- + component : type + The component class to inspect. + + Returns + ------- + List[str] + Deduplicated compatible component names in MRO order. + """ + compatible_components: List[str] = [] + for klass in component.__mro__: + for entry in vars(klass).get("COMPATIBLE_COMPONENTS", []): + if entry not in compatible_components: + compatible_components.append(entry) + return compatible_components + @beartype def register_component(self, new_component: Type) -> None: """Register a component within the registry. @@ -225,12 +252,11 @@ def register_component(self, new_component: Type) -> None: else: self._registry[base_type][new_component.__name__] = new_register_component - if hasattr(new_component, "COMPATIBLE_COMPONENTS"): - for compatible_component in new_component.COMPATIBLE_COMPONENTS: - self._relationship_manager.add_relationship( - new_component.__name__, - compatible_component, - ) + for compatible_component in self._collect_compatible_components(new_component): + self._relationship_manager.add_relationship( + new_component.__name__, + compatible_component, + ) @beartype def unregister_component(self, component: Type) -> None: @@ -254,12 +280,11 @@ def unregister_component(self, component: Type) -> None: f"in the registry. Exception: {e}" ) from e - if hasattr(component, "COMPATIBLE_COMPONENTS"): - for compatible_component in component.COMPATIBLE_COMPONENTS: - self._relationship_manager.remove_relationship( - component.__name__, - compatible_component, - ) + for compatible_component in self._collect_compatible_components(component): + self._relationship_manager.remove_relationship( + component.__name__, + compatible_component, + ) @beartype def get_components_by_types( diff --git a/DashAI/front/src/components/explainers/InlineExplainerCreator.jsx b/DashAI/front/src/components/explainers/InlineExplainerCreator.jsx index 753d80897..4b338a261 100644 --- a/DashAI/front/src/components/explainers/InlineExplainerCreator.jsx +++ b/DashAI/front/src/components/explainers/InlineExplainerCreator.jsx @@ -55,7 +55,7 @@ export default function InlineExplainerCreator({ const { t } = useTranslation(["explainers", "common"]); const formSubmitRef = useRef(null); - const { runId, taskName } = explainerConfig; + const { runId, taskName, modelName } = explainerConfig; const isLocal = scope === "local"; const defaultNewExplainer = useMemo( @@ -309,6 +309,7 @@ export default function InlineExplainerCreator({ setNextEnabled={setNextEnabled} scope={isLocal ? "Local" : "Global"} taskName={taskName} + modelName={modelName} existingExplainers={existingExplainers} /> )} @@ -368,6 +369,7 @@ InlineExplainerCreator.propTypes = { explainerConfig: PropTypes.shape({ runId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), taskName: PropTypes.string, + modelName: PropTypes.string, }).isRequired, onCreated: PropTypes.func, onCancel: PropTypes.func.isRequired, diff --git a/DashAI/front/src/components/explainers/NewGlobalExplainerModal.jsx b/DashAI/front/src/components/explainers/NewGlobalExplainerModal.jsx index 47f703038..36791161b 100644 --- a/DashAI/front/src/components/explainers/NewGlobalExplainerModal.jsx +++ b/DashAI/front/src/components/explainers/NewGlobalExplainerModal.jsx @@ -78,7 +78,7 @@ export default function NewGlobalExplainerModal({ const { enqueueSnackbar } = useSnackbar(); - const { runId, taskName } = explainerConfig; + const { runId, taskName, modelName } = explainerConfig; const defaultNewGlobalExpl = { name: "", @@ -331,6 +331,7 @@ export default function NewGlobalExplainerModal({ setNextEnabled={setNextEnabled} scope={"Global"} taskName={taskName} + modelName={modelName} existingExplainers={existingGlobalExplainers} /> )} diff --git a/DashAI/front/src/components/explainers/NewLocalExplainerModal.jsx b/DashAI/front/src/components/explainers/NewLocalExplainerModal.jsx index cf13aefe1..58c337208 100644 --- a/DashAI/front/src/components/explainers/NewLocalExplainerModal.jsx +++ b/DashAI/front/src/components/explainers/NewLocalExplainerModal.jsx @@ -80,7 +80,7 @@ export default function NewLocalExplainerModal({ const { enqueueSnackbar } = useSnackbar(); - const { runId, taskName } = explainerConfig; + const { runId, taskName, modelName } = explainerConfig; const defaultNewLocalExpl = { name: "", @@ -337,6 +337,7 @@ export default function NewLocalExplainerModal({ setNextEnabled={setNextEnabled} scope={"Local"} taskName={taskName} + modelName={modelName} existingExplainers={existingLocalExplainers} /> )} diff --git a/DashAI/front/src/components/explainers/SetNameAndExplainerStep.jsx b/DashAI/front/src/components/explainers/SetNameAndExplainerStep.jsx index fedf5942d..697a40530 100644 --- a/DashAI/front/src/components/explainers/SetNameAndExplainerStep.jsx +++ b/DashAI/front/src/components/explainers/SetNameAndExplainerStep.jsx @@ -13,6 +13,7 @@ function SetNameAndExplainerStep({ setNextEnabled, scope, taskName, + modelName, existingExplainers = [], }) { const { enqueueSnackbar } = useSnackbar(); @@ -31,9 +32,26 @@ function SetNameAndExplainerStep({ const getExplainers = async () => { setLoading(true); try { - const result = await getComponentsRequest({ - selectTypes: [`${scope}Explainer`], - relatedComponent: taskName, + // Explainers related to the task are model agnostic (usable by any + // model of the task); explainers related to the run's model are + // model specific ones the model declares in COMPATIBLE_COMPONENTS. + const [taskRelated, modelRelated] = await Promise.all([ + getComponentsRequest({ + selectTypes: [`${scope}Explainer`], + relatedComponent: taskName, + }), + modelName + ? getComponentsRequest({ + selectTypes: [`${scope}Explainer`], + relatedComponent: modelName, + }) + : Promise.resolve([]), + ]); + const seen = new Set(); + const result = [...taskRelated, ...modelRelated].filter((obj) => { + if (seen.has(obj.name)) return false; + seen.add(obj.name); + return true; }); setExplainers(result.filter((obj) => !obj.name.startsWith("Fit"))); } catch (error) { @@ -165,6 +183,7 @@ SetNameAndExplainerStep.propTypes = { setNextEnabled: PropTypes.func.isRequired, scope: PropTypes.string.isRequired, taskName: PropTypes.string, + modelName: PropTypes.string, existingExplainers: PropTypes.array, }; diff --git a/DashAI/front/src/components/models/RunResults.jsx b/DashAI/front/src/components/models/RunResults.jsx index 7b34cf45d..b9ade14f2 100644 --- a/DashAI/front/src/components/models/RunResults.jsx +++ b/DashAI/front/src/components/models/RunResults.jsx @@ -426,6 +426,7 @@ export default function RunResults({ explainerConfig={{ runId: run.id, taskName: session?.task_name, + modelName: run.model_name, }} onCreated={handleExplainerCreated} onCancel={() => setGlobalCreatorOpen(false)} @@ -436,6 +437,7 @@ export default function RunResults({ explainerConfig={{ runId: run.id, taskName: session?.task_name, + modelName: run.model_name, }} onCreated={handleExplainerCreated} onCancel={() => setLocalCreatorOpen(false)} diff --git a/tests/back/registries/test_registry.py b/tests/back/registries/test_registry.py index c15b17238..51e7bae3e 100644 --- a/tests/back/registries/test_registry.py +++ b/tests/back/registries/test_registry.py @@ -70,6 +70,19 @@ class ComponentWithTwoBaseClasses(BaseConfigComponent1, BaseConfigComponent2): . class NoComponent: ... +class RelatedMixin: + COMPATIBLE_COMPONENTS = ["Component1"] + + +class RelatedParentComponent(BaseStaticComponent): + COMPATIBLE_COMPONENTS = ["Component2"] + + +class CombinedRelatedComponent(RelatedMixin, RelatedParentComponent): + # Redeclares an inherited entry on purpose: the union must deduplicate. + COMPATIBLE_COMPONENTS = ["Component1"] + + COMPONENT1_DICT = { "name": "Component1", "type": "ConfigComponent1", @@ -482,3 +495,35 @@ def test_relationships_module(): COMPONENT1_DICT, COMPONENT2_DICT, ] + + +def test_compatible_components_merge_across_bases(): + test_registry = ComponentRegistry( + initial_components=[ + Component1, + Component2, + ] + ) + + test_registry.register_component(CombinedRelatedComponent) + + # The mixin contributes "Component1", the static base "Component2"; + # the subclass redeclaration of "Component1" does not duplicate it. + assert test_registry._relationship_manager["CombinedRelatedComponent"] == [ + "Component1", + "Component2", + ] + assert test_registry.get_related_components("CombinedRelatedComponent") == [ + COMPONENT1_DICT, + COMPONENT2_DICT, + ] + assert [ + component["name"] + for component in test_registry.get_related_components("Component2") + ] == ["CombinedRelatedComponent"] + + # Unregistering removes both inherited edges symmetrically. + test_registry.unregister_component(CombinedRelatedComponent) + assert test_registry._relationship_manager["CombinedRelatedComponent"] == [] + assert test_registry.get_related_components("Component1") == [] + assert test_registry.get_related_components("Component2") == []