Repository: dstackai/gpuhunt

Issue:
[Bug]: Fleet doesn't respect resource spec properly
### Steps to reproduce

1. Define a fleet configuration

```
type: fleet
name: default-fleet

nodes: 0..2

resources:
  gpu: 24GB..80GB:0..2
```

2. Create the fleet

### Actual behaviour

1. It shows only offers with GPU

### Expected behaviour

1. It should include non-GPU offers too (gpu count is 0..2)

### dstack version

0.19.34

### Server logs

```shell

```

### Additional information

_No response_

Discussion:

Fix (patch):
diff --git a/src/gpuhunt/_internal/constraints.py b/src/gpuhunt/_internal/constraints.py
index b7239a9..9a75b9f 100644
--- a/src/gpuhunt/_internal/constraints.py
+++ b/src/gpuhunt/_internal/constraints.py
@@ -63,31 +63,35 @@ def matches(i: CatalogItem, q: QueryFilter) -> bool:
         return False
     if not is_between(i.memory, q.min_memory, q.max_memory):
         return False
-    if q.gpu_vendor and q.gpu_vendor != i.gpu_vendor:
-        return False
-    if not is_between(i.gpu_count, q.min_gpu_count, q.max_gpu_count):
-        return False
-    if q.gpu_name is not None:
-        if i.gpu_name is None:
+    if not (q.min_gpu_count == 0 and i.gpu_count == 0):
+        # GPU filters should not be applied to non-gpu offers if `q.min_gpu_count == 0`.
+        if q.gpu_vendor and q.gpu_vendor != i.gpu_vendor:
             return False
-        if i.gpu_name.lower() not in map(str.lower, q.gpu_name):
+        if not is_between(i.gpu_count, q.min_gpu_count, q.max_gpu_count):
             return False
-    if q.min_compute_capability is not None or q.max_compute_capability is not None:
-        if i.gpu_vendor != AcceleratorVendor.NVIDIA:
-            return False
-        if not i.gpu_name:
+        if q.gpu_name is not None:
+            if i.gpu_name is None:
+                return False
+            if i.gpu_name.lower() not in map(str.lower, q.gpu_name):
+                return False
+        if q.min_compute_capability is not None or q.max_compute_capability is not None:
+            if i.gpu_vendor != AcceleratorVendor.NVIDIA:
+                return False
+            if not i.gpu_name:
+                return False
+            cc = get_compute_capability(i.gpu_name)
+            if not cc or not is_between(cc, q.min_compute_capability, q.max_compute_capability):
+                return False
+        if not is_between(
+            i.gpu_memory if i.gpu_count > 0 else 0, q.min_gpu_memory, q.max_gpu_memory
+        ):
             return False
-        cc = get_compute_capability(i.gpu_name)
-        if not cc or not is_between(cc, q.min_compute_capability, q.max_compute_capability):
+        if not is_between(
+            (i.gpu_count * i.gpu_memory) if i.gpu_count > 0 else 0,
+            q.min_total_gpu_memory,
+            q.max_total_gpu_memory,
+        ):
             return False
-    if not is_between(i.gpu_memory if i.gpu_count > 0 else 0, q.min_gpu_memory, q.max_gpu_memory):
-        return False
-    if not is_between(
-        (i.gpu_count * i.gpu_memory) if i.gpu_count > 0 else 0,
-        q.min_total_gpu_memory,
-        q.max_total_gpu_memory,
-    ):
-        return False
     if i.disk_size is not None:
         if not is_between(i.disk_size, q.min_disk_size, q.max_disk_size):
             return False


Test changes:
diff --git a/src/tests/_internal/test_constraints.py b/src/tests/_internal/test_constraints.py
index 8971808..5c5057c 100644
--- a/src/tests/_internal/test_constraints.py
+++ b/src/tests/_internal/test_constraints.py
@@ -194,6 +194,23 @@ def test_flags(
         q = QueryFilter(allowed_flags=query_allowed_flags)
         assert matches(item, q) == should_match
 
+    def test_matches_cpu_instances_with_zero_gpu_count_and_gpu_memory(self):
+        cpu_item = CatalogItem(
+            instance_name="large",
+            location="us-east-1",
+            price=1.2,
+            cpu=16,
+            memory=64.0,
+            gpu_vendor=None,
+            gpu_count=0,
+            gpu_name=None,
+            gpu_memory=None,
+            spot=False,
+            provider="aws",
+            disk_size=None,
+        )
+        assert matches(cpu_item, QueryFilter(min_gpu_count=0, min_gpu_memory=24))
+
 
 @pytest.mark.parametrize(
     ("gpu_name", "memory_mib", "expected_memory_gib"),