From 537f02fd3f0447e26cfa0741f19c896e4f6fca2f Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 10 May 2026 21:10:31 -0400 Subject: [PATCH 1/2] feat(lora): per-model configurable weight range Adds optional `weight_min` and `weight_max` to the LoRA model's default settings so slider LoRAs that operate at non-standard scales (e.g. -10..+10) can be tuned without bumping into the previously hard-coded -1..+2 slider range. The model edit panel exposes them alongside the starting weight, and the LoRA card slider in the canvas honors whatever range the user saved on the model. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backend/model_manager/configs/lora.py | 12 ++++- invokeai/frontend/web/public/locales/en.json | 4 ++ .../src/features/controlLayers/store/types.ts | 2 +- .../src/features/lora/components/LoRACard.tsx | 27 ++++++++--- .../hooks/useLoRAModelDefaultSettings.ts | 8 ++++ .../DefaultWeight.tsx | 32 ++++++------- .../DefaultWeightMax.tsx | 46 +++++++++++++++++++ .../DefaultWeightMin.tsx | 46 +++++++++++++++++++ .../LoRAModelDefaultSettings.tsx | 27 +++++++++-- .../frontend/web/src/services/api/schema.ts | 10 ++++ 10 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeightMax.tsx create mode 100644 invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeightMin.tsx diff --git a/invokeai/backend/model_manager/configs/lora.py b/invokeai/backend/model_manager/configs/lora.py index 46606a3c0d5..172196d79d7 100644 --- a/invokeai/backend/model_manager/configs/lora.py +++ b/invokeai/backend/model_manager/configs/lora.py @@ -6,7 +6,7 @@ Self, ) -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from invokeai.backend.model_manager.configs.base import ( Config_Base, @@ -39,9 +39,17 @@ class LoraModelDefaultSettings(BaseModel): - weight: float | None = Field(default=None, ge=-1, le=2, description="Default weight for this model") + weight: float | None = Field(default=None, description="Default weight for this model") + weight_min: float | None = Field(default=None, description="Minimum weight slider value for this model") + weight_max: float | None = Field(default=None, description="Maximum weight slider value for this model") model_config = ConfigDict(extra="forbid") + @model_validator(mode="after") + def _validate_weight_bounds(self) -> "LoraModelDefaultSettings": + if self.weight_min is not None and self.weight_max is not None and self.weight_min >= self.weight_max: + raise ValueError("weight_min must be less than weight_max") + return self + class LoRA_Config_Base(ABC, BaseModel): """Base class for LoRA models.""" diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index e7631ff6236..d5536f63889 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1004,6 +1004,10 @@ }, "lora": { "weight": "Weight", + "startingWeight": "Starting Weight", + "weightMin": "Min Weight", + "weightMax": "Max Weight", + "weightMinMustBeLessThanMax": "Min weight must be less than max weight", "removeLoRA": "Remove LoRA" }, "metadata": { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 7a7ebeade71..baa31916a03 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -664,7 +664,7 @@ export const zLoRA = z.object({ id: z.string(), isEnabled: z.boolean(), model: zModelIdentifierField, - weight: z.number().gte(-10).lte(10), + weight: z.number(), }); export type LoRA = z.infer; diff --git a/invokeai/frontend/web/src/features/lora/components/LoRACard.tsx b/invokeai/frontend/web/src/features/lora/components/LoRACard.tsx index 87dc8c2a190..8b118a3a12c 100644 --- a/invokeai/frontend/web/src/features/lora/components/LoRACard.tsx +++ b/invokeai/frontend/web/src/features/lora/components/LoRACard.tsx @@ -23,8 +23,7 @@ import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleBold } from 'react-icons/pi'; import { useGetModelConfigQuery } from 'services/api/endpoints/models'; - -const MARKS = [-1, 0, 1, 2]; +import { isLoRAModelConfig } from 'services/api/types'; export const LoRACard = memo((props: { id: string }) => { const selectLoRA = useMemo(() => buildSelectLoRA(props.id), [props.id]); @@ -58,6 +57,20 @@ const LoRAContent = memo(({ lora }: { lora: LoRA }) => { dispatch(loraDeleted({ id: lora.id })); }, [dispatch, lora.id]); + const loraDefaults = loraConfig && isLoRAModelConfig(loraConfig) ? loraConfig.default_settings : null; + const sliderMin = loraDefaults?.weight_min ?? DEFAULT_LORA_WEIGHT_CONFIG.sliderMin; + const sliderMax = loraDefaults?.weight_max ?? DEFAULT_LORA_WEIGHT_CONFIG.sliderMax; + const numberInputMin = Math.min(sliderMin, DEFAULT_LORA_WEIGHT_CONFIG.numberInputMin); + const numberInputMax = Math.max(sliderMax, DEFAULT_LORA_WEIGHT_CONFIG.numberInputMax); + + const marks = useMemo(() => { + if (sliderMin >= sliderMax) { + return [sliderMin, sliderMax]; + } + const mid = (sliderMin + sliderMax) / 2; + return [sliderMin, mid, sliderMax]; + }, [sliderMin, sliderMax]); + return ( @@ -82,19 +95,19 @@ const LoRAContent = memo(({ lora }: { lora: LoRA }) => { { isEnabled: !isNil(modelConfig?.default_settings?.weight), value: modelConfig?.default_settings?.weight ?? DEFAULT_LORA_WEIGHT_CONFIG.initial, }, + weightMin: { + isEnabled: !isNil(modelConfig?.default_settings?.weight_min), + value: modelConfig?.default_settings?.weight_min ?? DEFAULT_LORA_WEIGHT_CONFIG.sliderMin, + }, + weightMax: { + isEnabled: !isNil(modelConfig?.default_settings?.weight_max), + value: modelConfig?.default_settings?.weight_max ?? DEFAULT_LORA_WEIGHT_CONFIG.sliderMax, + }, }; }, [modelConfig?.default_settings]); diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight.tsx index c26ce38c671..d5941bff3de 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight.tsx @@ -1,22 +1,26 @@ -import { CompositeNumberInput, CompositeSlider, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; import { DEFAULT_LORA_WEIGHT_CONFIG } from 'features/controlLayers/store/lorasSlice'; import { SettingToggle } from 'features/modelManagerV2/subpanels/ModelPanel/SettingToggle'; import { memo, useCallback, useMemo } from 'react'; import type { UseControllerProps } from 'react-hook-form'; -import { useController } from 'react-hook-form'; +import { useController, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import type { LoRAModelDefaultSettingsFormData } from './LoRAModelDefaultSettings'; -const MARKS = [-1, 0, 1, 2]; - -type DefaultWeight = LoRAModelDefaultSettingsFormData['weight']; - export const DefaultWeight = memo((props: UseControllerProps) => { const { field } = useController(props); const { t } = useTranslation(); + const weightMin = useWatch({ control: props.control, name: 'weightMin' }); + const weightMax = useWatch({ control: props.control, name: 'weightMax' }); + + const sliderMin = weightMin?.isEnabled ? weightMin.value : DEFAULT_LORA_WEIGHT_CONFIG.sliderMin; + const sliderMax = weightMax?.isEnabled ? weightMax.value : DEFAULT_LORA_WEIGHT_CONFIG.sliderMax; + const numberInputMin = Math.min(sliderMin, DEFAULT_LORA_WEIGHT_CONFIG.numberInputMin); + const numberInputMax = Math.max(sliderMax, DEFAULT_LORA_WEIGHT_CONFIG.numberInputMax); + const onChange = useCallback( (v: number) => { const updatedValue = { @@ -40,26 +44,16 @@ export const DefaultWeight = memo((props: UseControllerProps - {t('lora.weight')} + {t('lora.startingWeight')} - ) => { + const { field } = useController(props); + const { t } = useTranslation(); + + const onChange = useCallback( + (v: number) => { + field.onChange({ ...field.value, value: v }); + }, + [field] + ); + + const value = useMemo(() => field.value.value, [field.value]); + const isDisabled = useMemo(() => !field.value.isEnabled, [field.value]); + + return ( + + + {t('lora.weightMax')} + + + + + + + ); +}); + +DefaultWeightMax.displayName = 'DefaultWeightMax'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeightMin.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeightMin.tsx new file mode 100644 index 00000000000..f37d2554b51 --- /dev/null +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeightMin.tsx @@ -0,0 +1,46 @@ +import { CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { DEFAULT_LORA_WEIGHT_CONFIG } from 'features/controlLayers/store/lorasSlice'; +import { SettingToggle } from 'features/modelManagerV2/subpanels/ModelPanel/SettingToggle'; +import { memo, useCallback, useMemo } from 'react'; +import type { UseControllerProps } from 'react-hook-form'; +import { useController } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import type { LoRAModelDefaultSettingsFormData } from './LoRAModelDefaultSettings'; + +export const DefaultWeightMin = memo((props: UseControllerProps) => { + const { field } = useController(props); + const { t } = useTranslation(); + + const onChange = useCallback( + (v: number) => { + field.onChange({ ...field.value, value: v }); + }, + [field] + ); + + const value = useMemo(() => field.value.value, [field.value]); + const isDisabled = useMemo(() => !field.value.isEnabled, [field.value]); + + return ( + + + {t('lora.weightMin')} + + + + + + + ); +}); + +DefaultWeightMin.displayName = 'DefaultWeightMin'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx index 2f509caa726..56a8f4f0feb 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx @@ -2,6 +2,8 @@ import { Button, Flex, Heading, SimpleGrid } from '@invoke-ai/ui-library'; import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsModelManagerEnabled'; import { useLoRAModelDefaultSettings } from 'features/modelManagerV2/hooks/useLoRAModelDefaultSettings'; import { DefaultWeight } from 'features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight'; +import { DefaultWeightMax } from 'features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeightMax'; +import { DefaultWeightMin } from 'features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeightMin'; import type { FormField } from 'features/modelManagerV2/subpanels/ModelPanel/MainModelDefaultSettings/MainModelDefaultSettings'; import { toast } from 'features/toast/toast'; import { memo, useCallback, useEffect } from 'react'; @@ -14,6 +16,8 @@ import type { LoRAModelConfig } from 'services/api/types'; export type LoRAModelDefaultSettingsFormData = { weight: FormField; + weightMin: FormField; + weightMax: FormField; }; type Props = { @@ -28,7 +32,7 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { const [updateModel, { isLoading: isLoadingUpdateModel }] = useUpdateModelMutation(); - const { handleSubmit, control, formState, reset } = useForm({ + const { handleSubmit, control, formState, reset, setError } = useForm({ defaultValues: defaultSettingsDefaults, }); @@ -38,8 +42,23 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { const onSubmit = useCallback>( (data) => { + const weightMin = data.weightMin.isEnabled ? data.weightMin.value : null; + const weightMax = data.weightMax.isEnabled ? data.weightMax.value : null; + + if (weightMin !== null && weightMax !== null && weightMin >= weightMax) { + setError('weightMin', { type: 'manual', message: t('lora.weightMinMustBeLessThanMax') }); + toast({ + id: 'DEFAULT_SETTINGS_SAVE_FAILED', + title: t('lora.weightMinMustBeLessThanMax'), + status: 'error', + }); + return; + } + const body = { weight: data.weight.isEnabled ? data.weight.value : null, + weight_min: weightMin, + weight_max: weightMax, }; updateModel({ @@ -65,7 +84,7 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { } }); }, - [updateModel, modelConfig.key, t, reset] + [updateModel, modelConfig.key, t, reset, setError] ); return ( @@ -86,8 +105,10 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { )} - + + + ); diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 63e365ce332..a80610ebf1d 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -19339,6 +19339,16 @@ export type components = { * @description Default weight for this model */ weight?: number | null; + /** + * Weight Min + * @description Minimum weight slider value for this model + */ + weight_min?: number | null; + /** + * Weight Max + * @description Maximum weight slider value for this model + */ + weight_max?: number | null; }; /** MDControlListOutput */ MDControlListOutput: { From c976a53a99483e14779cc370ce53c988ebb0fd5b Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 14 May 2026 16:54:28 -0400 Subject: [PATCH 2/2] fix(lora): validate effective weight range and weight-in-range Address review feedback on partial-bound and weight-out-of-range cases: - Backend LoraModelDefaultSettings now computes an effective slider range using frontend-matching defaults (-1, 2) when one bound is unset, and rejects saves where effective_min >= effective_max or where the starting weight falls outside that range. Previously, saving only weight_min=3 (or weight_max<=-1) was accepted and produced an inverted slider in LoRACard, and weight=10 with bounds [-1, 2] was persisted unchanged. - Mirror the same checks in LoRAModelDefaultSettings.tsx onSubmit so invalid configurations are surfaced inline before the API call. - Add backend tests covering partial bounds and out-of-range weight. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backend/model_manager/configs/lora.py | 21 +++- invokeai/frontend/web/public/locales/en.json | 1 + .../LoRAModelDefaultSettings.tsx | 22 +++- .../configs/test_lora_default_settings.py | 112 ++++++++++++++++++ 4 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 tests/backend/model_manager/configs/test_lora_default_settings.py diff --git a/invokeai/backend/model_manager/configs/lora.py b/invokeai/backend/model_manager/configs/lora.py index 172196d79d7..394085be151 100644 --- a/invokeai/backend/model_manager/configs/lora.py +++ b/invokeai/backend/model_manager/configs/lora.py @@ -38,6 +38,15 @@ from invokeai.backend.patches.lora_conversions.flux_control_lora_utils import is_state_dict_likely_flux_control +# Defaults used to compute the effective slider range when one or both bounds +# are unset. These intentionally mirror the frontend's DEFAULT_LORA_WEIGHT_CONFIG +# in invokeai/frontend/web/src/features/controlLayers/store/lorasSlice.ts so that +# bound/weight validation produces the same result whether it runs in the form +# or in this pydantic model. +_DEFAULT_LORA_WEIGHT_SLIDER_MIN = -1.0 +_DEFAULT_LORA_WEIGHT_SLIDER_MAX = 2.0 + + class LoraModelDefaultSettings(BaseModel): weight: float | None = Field(default=None, description="Default weight for this model") weight_min: float | None = Field(default=None, description="Minimum weight slider value for this model") @@ -46,8 +55,16 @@ class LoraModelDefaultSettings(BaseModel): @model_validator(mode="after") def _validate_weight_bounds(self) -> "LoraModelDefaultSettings": - if self.weight_min is not None and self.weight_max is not None and self.weight_min >= self.weight_max: - raise ValueError("weight_min must be less than weight_max") + effective_min = self.weight_min if self.weight_min is not None else _DEFAULT_LORA_WEIGHT_SLIDER_MIN + effective_max = self.weight_max if self.weight_max is not None else _DEFAULT_LORA_WEIGHT_SLIDER_MAX + if effective_min >= effective_max: + raise ValueError( + f"effective weight range is invalid: min ({effective_min}) must be less than max ({effective_max})" + ) + if self.weight is not None and not (effective_min <= self.weight <= effective_max): + raise ValueError( + f"weight ({self.weight}) must be within the effective range [{effective_min}, {effective_max}]" + ) return self diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 6445a79bd29..dde874249be 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1008,6 +1008,7 @@ "weightMin": "Min Weight", "weightMax": "Max Weight", "weightMinMustBeLessThanMax": "Min weight must be less than max weight", + "weightOutOfRange": "Starting weight must be within the min/max range", "removeLoRA": "Remove LoRA" }, "metadata": { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx index 56a8f4f0feb..cf792cce325 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/LoRAModelDefaultSettings.tsx @@ -1,4 +1,5 @@ import { Button, Flex, Heading, SimpleGrid } from '@invoke-ai/ui-library'; +import { DEFAULT_LORA_WEIGHT_CONFIG } from 'features/controlLayers/store/lorasSlice'; import { useIsModelManagerEnabled } from 'features/modelManagerV2/hooks/useIsModelManagerEnabled'; import { useLoRAModelDefaultSettings } from 'features/modelManagerV2/hooks/useLoRAModelDefaultSettings'; import { DefaultWeight } from 'features/modelManagerV2/subpanels/ModelPanel/LoRAModelDefaultSettings/DefaultWeight'; @@ -44,8 +45,15 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { (data) => { const weightMin = data.weightMin.isEnabled ? data.weightMin.value : null; const weightMax = data.weightMax.isEnabled ? data.weightMax.value : null; + const weight = data.weight.isEnabled ? data.weight.value : null; - if (weightMin !== null && weightMax !== null && weightMin >= weightMax) { + // Compute effective bounds the same way LoRACard does when rendering the slider, + // so partial bounds (only min or only max) get validated against the fallback + // default for the other side. + const effectiveMin = weightMin ?? DEFAULT_LORA_WEIGHT_CONFIG.sliderMin; + const effectiveMax = weightMax ?? DEFAULT_LORA_WEIGHT_CONFIG.sliderMax; + + if (effectiveMin >= effectiveMax) { setError('weightMin', { type: 'manual', message: t('lora.weightMinMustBeLessThanMax') }); toast({ id: 'DEFAULT_SETTINGS_SAVE_FAILED', @@ -55,8 +63,18 @@ export const LoRAModelDefaultSettings = memo(({ modelConfig }: Props) => { return; } + if (weight !== null && (weight < effectiveMin || weight > effectiveMax)) { + setError('weight', { type: 'manual', message: t('lora.weightOutOfRange') }); + toast({ + id: 'DEFAULT_SETTINGS_SAVE_FAILED', + title: t('lora.weightOutOfRange'), + status: 'error', + }); + return; + } + const body = { - weight: data.weight.isEnabled ? data.weight.value : null, + weight, weight_min: weightMin, weight_max: weightMax, }; diff --git a/tests/backend/model_manager/configs/test_lora_default_settings.py b/tests/backend/model_manager/configs/test_lora_default_settings.py new file mode 100644 index 00000000000..2e80db90ad1 --- /dev/null +++ b/tests/backend/model_manager/configs/test_lora_default_settings.py @@ -0,0 +1,112 @@ +import pytest +from pydantic import ValidationError + +from invokeai.backend.model_manager.configs.lora import LoraModelDefaultSettings + + +def test_accepts_none_for_all_fields() -> None: + settings = LoraModelDefaultSettings() + assert settings.weight is None + assert settings.weight_min is None + assert settings.weight_max is None + + +def test_accepts_both_bounds_with_min_less_than_max() -> None: + settings = LoraModelDefaultSettings(weight_min=-2.0, weight_max=3.0) + assert settings.weight_min == -2.0 + assert settings.weight_max == 3.0 + + +def test_rejects_both_bounds_with_min_greater_than_or_equal_to_max() -> None: + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight_min=2.0, weight_max=2.0) + + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight_min=3.0, weight_max=1.0) + + +def test_accepts_only_weight_min_within_default_range() -> None: + # Default max is 2.0; a weight_min of 1.0 leaves a valid effective range [1.0, 2.0]. + settings = LoraModelDefaultSettings(weight_min=1.0) + assert settings.weight_min == 1.0 + assert settings.weight_max is None + + +def test_rejects_only_weight_min_above_default_max() -> None: + # Reproduces the partial-bound bug: saving only weight_min=3 used to be accepted but + # rendered as a min>max slider in LoRACard. The effective max defaults to 2.0. + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight_min=3.0) + + +def test_rejects_only_weight_min_equal_to_default_max() -> None: + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight_min=2.0) + + +def test_accepts_only_weight_max_within_default_range() -> None: + # Default min is -1.0; a weight_max of 0.5 leaves a valid effective range [-1.0, 0.5]. + settings = LoraModelDefaultSettings(weight_max=0.5) + assert settings.weight_max == 0.5 + assert settings.weight_min is None + + +def test_rejects_only_weight_max_below_default_min() -> None: + # Reproduces the symmetric partial-bound bug for weight_max <= -1. + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight_max=-2.0) + + +def test_rejects_only_weight_max_equal_to_default_min() -> None: + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight_max=-1.0) + + +def test_accepts_weight_within_effective_range_with_explicit_bounds() -> None: + settings = LoraModelDefaultSettings(weight=0.5, weight_min=-1.0, weight_max=2.0) + assert settings.weight == 0.5 + + +def test_accepts_weight_within_default_effective_range() -> None: + # With no bounds set the effective range is [-1.0, 2.0]. + settings = LoraModelDefaultSettings(weight=1.5) + assert settings.weight == 1.5 + + +def test_rejects_weight_above_explicit_effective_range() -> None: + # Reproduces the out-of-range-weight bug: weight=10 with bounds [-1, 2] used to be + # accepted but the slider in LoRACard could only show [-1, 2]. + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight=10.0, weight_min=-1.0, weight_max=2.0) + + +def test_rejects_weight_below_explicit_effective_range() -> None: + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight=-5.0, weight_min=-1.0, weight_max=2.0) + + +def test_rejects_weight_above_default_effective_range() -> None: + # No bounds set; default max is 2.0. + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight=2.5) + + +def test_rejects_weight_outside_partial_bound() -> None: + # Only weight_min set; weight must respect effective range [weight_min, default_max]. + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight=0.0, weight_min=0.5) + + # Only weight_max set; weight must respect effective range [default_min, weight_max]. + with pytest.raises(ValidationError): + LoraModelDefaultSettings(weight=1.0, weight_max=0.5) + + +def test_accepts_weight_at_effective_bounds() -> None: + # Inclusive bounds. + LoraModelDefaultSettings(weight=-1.0, weight_min=-1.0, weight_max=2.0) + LoraModelDefaultSettings(weight=2.0, weight_min=-1.0, weight_max=2.0) + + +def test_rejects_extra_fields() -> None: + with pytest.raises(ValidationError): + LoraModelDefaultSettings(extra_field=True) # type: ignore[call-arg]