Review · updated OCT 11

Ministral 3 3B 2512 review: not one we'd recommend right now

It scored 21 out of 100, #50 of 56. It solved 3 of 30 coding jobs and scored 32 on reading documents. Runs on an 8 GB graphics card or a Mac with 16 GB.

The short version
  • Ministral 3 3B 2512 is a free model from Mistral AI that you can run on your own computer. In our tests it's not one we'd recommend right now: 21 out of 100, #50 of 56.
  • It solved 3 of 30 coding jobs and scored 32 on reading documents. On our hardest tasks it scored 15.
  • Runs on an 8 GB graphics card or a Mac with 16 GB.

Coding

Our coding test is 30 programming jobs, from small ones like reading time durations or cleaning up messy data to harder ones like a config-file parser or a double-entry ledger. We run each answer against tests the model never sees, and a job only counts if everything passes. Ministral 3 3B 2512 got 3 of 30 right. The best local coders solved 29 of 30.

Reading documents

The second test hands the model things like an expense claim thread, a pay stub or an insurance statement, and asks for specific numbers and dates. Many questions need a bit of math, or noticing a correction further down the email. Ministral 3 3B 2512 scored 32; the best model scored 100.

TestScorePublic questionsSecret questions
Coding10149
Reading documents325226
Decisions726973

On the 18 hardest tasks (included in the scores above) it scored 15. This number separates the top models.

This model did much better on our public questions than on the secret ones. Sometimes that's luck; it can also mean the model has seen the public questions before.

We tested the full-size version online. The smaller versions you download usually score a little lower.

Can your computer run it?

You can download Ministral 3 3B 2512 in several sizes. Smaller versions fit on more computers and lose a little quality, so pick the biggest one that fits.

Your computerRuns it?Version to download
Graphics card, 8 GBYesQ8
Graphics card, 12 GBYesQ8
Graphics card, 16 GBYesQ8
Graphics card, 24 GBYesQ8
Graphics card, 32 GBYesQ8
Mac, 16 GBYesQ8
Mac, 24 GBYesQ8
Mac, 32 GBYesQ8
Mac, 48 GBYesQ8
Mac, 64 GBYesQ8
Mac, 96 GBYesQ8
Mac, 128 GBYesQ8

For normal-length chats. Long documents need more memory. Q4 or Q8? · How we worked this out

How to try it

Search for "Ministral 3 3B 2512 GGUF" in LM Studio or on Hugging Face and pick the version that fits your computer. New to this? Start with our Ollama guide.

Want the strongest all-rounder instead? Qwen3.6 27B scored 97.

Its actual answers

What Ministral 3 3B 2512 wrote for our public questions, word for word.

Coding: INI parser (hard) Didn't work
```python
import re
from typing import Dict, Any

def parse_ini(text: str) -> Dict[str, Dict[str, str]]:
    lines = text.splitlines()
    current_section = None
    current_section_data = {}
    sections = {}
    default_keys = {}
    in_quoted_value = False
    interpolation_stack = []

    for line in lines:
        stripped = line.strip()
        if not stripped or (stripped.startswith((';', '#')) and not in_quoted_value):
            continue

        if in_quoted_value:
            # Handle quoted value continuation
            if line.endswith('"'):
                in_quoted_value = False
            current_section_data[last_key] = (current_section_data.get(last_key, '') + line[:-1]).strip()
            continue

        # Check for section header
        if line.startswith('[') and line.endswith(']'):
            section_name = line[1:-1].strip()
            if section_name == 'DEFAULT':
                current_section = None
                current_section_data = {}
                continue
            current_section = section_name
            sections[current_section] = {}
            if current_section != 'DEFAULT':
                current_section_data = sections[current_section]
            else:
                current_section_data = {}
            continue

        # Check for key-value pair
        if not current_section:
            raise ValueError("Key line before any section header")

        # Parse key-value pair
        parts = []
        key = None
        value = None
        in_key = True

        # Handle quoted values
        if line.startswith('"'):
            in_quoted_value = True
            value = line[1:].strip()
            if not value.endswith('"'):
                raise ValueError("Unterminated quoted value")
            continue

        # Split on first = or :
        split_pos = min(line.find('='), line.find(':'))
        if split_pos == -1:
            raise ValueError("Malformed key-value pair")

        if split_pos == 0:
            raise ValueError("Key cannot start with = or :")

        key_part = line[:split_pos].strip()
        if not key_part:
            raise ValueError("Empty key")

        key = key_part.lower()
        remaining = line[split_pos:].strip()

        # Handle continuation lines
        if remaining.startswith('"'):
            in_quoted_value = True
            value = remaining[1:].strip()
            if not value.endswith('"'):
                raise ValueError("Unterminated quoted value")
            last_key = key
            continue

        # Check for interpolation
        if remaining.startswith('${'):
            # Handle interpolation
            interpolation_match = re.match(r'^(\${[^}]+})$', remaining)
            if not interpolation_match:
                raise ValueError("Invalid interpolation syntax")
            interp_key = interpolation_match.group(1)[2:-1]

            # Handle section:key syntax
            if ':' in interp_key:
                section_key = interp_key.split(':')
                section_name = section_key[0]
                interp_key = section_key[1]
            else:
                section_name = current_section
                if section_name == 'DEFAULT':
                    section_name = None

            # Resolve interpolation
            if section_name is None:
                # DEFAULT section
                resolved_value = default_keys.get(interp_key, '')
            else:
                # Specific section
                resolved_value = sections.get(section_name, {}).get(interp_key, '')

            # Check for cycles
            if resolved_value in interpolation_stack:
                raise ValueError("Interpolation cycle detected")
            interpolation_stack.append(resolved_value)

            # Recursively resolve
            resolved_value = resolve_interpolation(resolved_value, sections, default_keys, current_section)
            interpolation_stack.pop()

            value = resolved_value
            last_key = key
            continue

        # Handle continuation lines
        if remaining.startswith('"'):
            in_quoted_value = True
            value = remaining[1:].strip()
            if not value.endswith('"'):
                raise ValueError("Unterminated quoted value")
            last_key = key
            continue

        # Normal value handling
        if remaining:
            value = remaining
        else:
            value = ''

        # Store key-value pair
        if key in current_section_data:
            # Later value wins
            pass
        else:
            current_section_data[key] = value

        last_key = key

    # Handle DEFAULT section keys
    for section in sections:
        if section == 'DEFAULT':
            continue
        for key, value in default_keys.items():
            if key not in sections[section]:
                sections[section][key] = value

    return sections

def resolve_interpolation(value: str, sections: Dict[str, Dict[str, str]], default_keys: Dict[str, str], current_section: str | None) -> str:
    # Handle interpolation in the value
    result = []
    i = 0
    while i < len(value):
        if value[i] == '${':
            # Find closing brace
            j = value.find('}', i)
            if j == -1:
                raise ValueError("Unterminated interpolation")
            interp_key = value[i+2:j]
            i = j + 1

            # Handle section:key syntax
            if ':' in interp_key:
                section_key = interp_key.split(':')
                section_name = section_key[0]
                interp_key = section_key[1]
            else:
                section_name = current_section
                if section_name == 'DEFAULT':
                    section_name = None

            # Resolve interpolation
            if section_name is None:
                # DEFAULT section
                resolved_value = default_keys.get(interp_key, '')
            else:
                # Specific section
                resolved_value = sections.get(section_name, {}).get(interp_key, '')

            # Recursively resolve
            resolved_value = resolve_interpolation(resolved_value, sections, default_keys, section_name)
            result.append(resolved_value)
        else:
            result.append(value[i])
            i += 1

    return ''.join(result)
```
Coding: TTL LRU (hard) Didn't work
```python
from collections import OrderedDict

class TTLCache:
    def __init__(self, capacity: int, ttl: float, clock):
        self.capacity = capacity
        self.ttl = ttl
        self.clock = clock
        self.cache = OrderedDict()
        self.stats = {
            "hits": 0,
            "misses": 0,
            "evictions": 0,
            "expirations": 0
        }

    def _clean_expired(self):
        now = self.clock()
        expired_keys = []
        for key in list(self.cache.keys()):
            expiry = self.cache[key]["expiry"]
            if expiry <= now:
                expired_keys.append(key)
        for key in expired_keys:
            del self.cache[key]
            self.stats["expirations"] += 1

    def put(self, key, value):
        if self.capacity == 0:
            return

        self._clean_expired()

        if key in self.cache:
            # Update existing entry
            self.cache.move_to_end(key)
            self.cache[key]["value"] = value
            self.cache[key]["expiry"] = self.clock() + self.ttl
        else:
            # Add new entry
            if len(self.cache) >= self.capacity:
                # Evict LRU
                self.cache.popitem(last=False)
                self.stats["evictions"] += 1

            self.cache[key] = {
                "value": value,
                "expiry": self.clock() + self.ttl
            }
            self.cache.move_to_end(key)

    def get(self, key, default=None):
        if self.capacity == 0:
            self.stats["misses"] += 1
            return default

        self._clean_expired()

        if key not in self.cache:
            self.stats["misses"] += 1
            return default

        entry = self.cache[key]
        if entry["expiry"] <= self.clock():
            del self.cache[key]
            self.stats["expirations"] += 1
            self.stats["misses"] += 1
            return default

        self.cache.move_to_end(key)
        self.stats["hits"] += 1
        return entry["value"]

    def __len__(self):
        now = self.clock()
        return sum(1 for entry in self.cache.values() if entry["expiry"] > now)

    def stats(self):
        return self.stats
```
Coding: Parse duration Didn't work
```python
from typing import Iterator

def parse_duration(s: str) -> int:
    if not s.strip():
        raise ValueError("Empty or blank string")

    def parse_part(part: str) -> tuple[int, str]:
        if not part:
            raise ValueError("No unit specified")
        try:
            num = int(part[:-1])
        except ValueError:
            raise ValueError("Invalid number format")
        unit = part[-1].upper()
        if unit not in {'H', 'M', 'S'}:
            raise ValueError("Unknown unit")
        return num, unit

    parts = []
    current_part = []
    for char in s:
        if char.isspace():
            if current_part:
                parts.append(''.join(current_part))
                current_part = []
        else:
            current_part.append(char)
    if current_part:
        parts.append(''.join(current_part))

    if not parts:
        raise ValueError("No valid parts found")

    # Check for repeated units and order
    seen_units = set()
    for part in parts:
        num, unit = parse_part(part)
        if unit in seen_units:
            raise ValueError("Repeated units")
        seen_units.add(unit)
        if unit not in {'H', 'M', 'S'}:
            raise ValueError("Invalid unit order")

    total_seconds = 0
    for part in parts:
        num, unit = parse_part(part)
        if unit == 'H':
            total_seconds += num * 3600
        elif unit == 'M':
            total_seconds += num * 60
        elif unit == 'S':
            total_seconds += num

    return total_seconds
```
Coding: Summarize ranges Didn't work
```python
from typing import List

def summarize_ranges(nums: List[int]) -> str:
    if not nums:
        return ""

    nums_sorted = sorted(set(nums))
    result = []
    start = nums_sorted[0]

    for num in nums_sorted[1:]:
        if num == start + 1:
            start = num
        else:
            if start == num - 1:
                result.append(f"{start}-{num}")
            elif start == num:
                result.append(str(start))
            else:
                result.append(f"{start}..{num}")
            start = num

    # Handle the last range
    if start == nums_sorted[-1]:
        result.append(str(start))
    elif start == nums_sorted[-1] - 1:
        result.append(f"{start}-{nums_sorted[-1]}")
    else:
        result.append(f"{start}..{nums_sorted[-1]}")

    return ",".join(result)
```
Coding: Top customers Didn't work
```python
from collections import defaultdict
import re

def top_customers(rows: list[dict], n: int) -> list[tuple[str, float]]:
    customer_totals = defaultdict(float)

    for row in rows:
        customer = row.get("customer", "").strip()
        if not customer:
            continue

        amount_str = row.get("amount", "")
        if not amount_str:
            continue

        try:
            # Remove commas and dollar sign, then parse as float
            amount_str = amount_str.replace(",", "").replace("$", "")
            amount = float(amount_str)
        except ValueError:
            continue

        # Use the first valid customer name encountered for case-insensitive matching
        if customer_totals.get(customer, None) is None:
            customer_totals[customer.lower()] = customer.strip()
        customer_name = customer_totals[customer.lower()]

        customer_totals[customer_name] += amount

    # Sort by total descending, then name ascending
    sorted_customers = sorted(
        customer_totals.items(),
        key=lambda item: (-item[1], item[0])
    )

    # Return top n customers as (name, total) tuples
    return [(name, round(total, 2)) for name, total in sorted_customers[:n]]
```
Coding: Slugify Didn't work
```python
import unicodedata
import re

def slugify(title: str, max_len: int = 50) -> str:
    # Step 1: Transliterate accented characters to ASCII and drop non-ASCII
    normalized = unicodedata.normalize('NFKD', title)
    ascii_only = ''.join(
        c for c in normalized
        if unicodedata.category(c).startswith('L') or c.isdigit()
    )

    # Step 2: Lowercase
    slug = ascii_only.lower()

    # Step 3: Replace runs of non-alphanumeric with single "-", strip leading/trailing "-"
    slug = re.sub(r'[^a-z0-9]+', '-', slug)
    slug = slug.strip('-')

    # Step 4: Handle length constraint
    if len(slug) > max_len:
        words = slug.split('-')
        if len(words) == 1:
            # Only one word, hard-cut it
            slug = slug[:max_len]
        else:
            # Find the longest prefix of whole words <= max_len
            total_len = 0
            new_words = []
            for word in words:
                if total_len + len(word) > max_len:
                    break
                new_words.append(word)
                total_len += len(word)
            slug = '-'.join(new_words)

    return slug
```
Coding: Token bucket Worked
```python
import time

class TokenBucket:
    def __init__(self, capacity: float, refill_per_sec: float, clock):
        self.capacity = capacity
        self.refill_per_sec = refill_per_sec
        self.clock = clock
        self._tokens = capacity
        self._last_refill_time = clock()

    def _refill(self):
        current_time = self.clock()
        elapsed = current_time - self._last_refill_time
        self._tokens = min(self.capacity, self._tokens + elapsed * self.refill_per_sec)
        self._last_refill_time = current_time

    def allow(self, cost: float = 1) -> bool:
        self._refill()
        if self._tokens >= cost:
            self._tokens -= cost
            return True
        return False

    @property
    def tokens(self) -> float:
        self._refill()
        return self._tokens
```
Decisions: Refund window (hard) 50% right
```json
{
  "outcome": {
    "full_refund": 0.0,
    "partial_refund": 0.9,
    "store_credit": 0.05,
    "nothing": 0.05
  },
  "defective": {
    "true": 0.0,
    "false": 1.0
  }
}
```
Decisions: Incident matrix (hard) 0% right
```json
{
  "severity": {
    "0": 0.0,
    "1": 0.0,
    "2": 0.1,
    "3": 0.9
  },
  "page": {
    "true": 0.9,
    "false": 0.1
  }
}
```
Decisions: Tool followup (hard) 100% right
```json
{
  "tool": {
    "calendar": 0.95,
    "none": 0.05
  },
  "confirm": {
    "true": 0.8,
    "false": 0.2
  }
}
```
Decisions: Legit security alert (hard) 0% right
```json
{
  "phishing": {
    "true": 0.95,
    "false": 0.05
  },
  "action_needed": {
    "true": 0.98,
    "false": 0.02
  }
}
```
Decisions: Meeting slot (hard) 0% right
```json
{
  "slot": {
    "A": 0.0,
    "B": 0.0,
    "C": 0.8,
    "D": 0.2
  },
  "raj_last": {
    "true": 0.0,
    "false": 1.0
  }
}
```
Decisions: Review mixed (hard) 100% right
```json
{
  "hardware": {
    "true": 0.95,
    "false": 0.05
  },
  "support": {
    "true": 0.98,
    "false": 0.02
  }
}
```
Decisions: Support checkout down 67% right
```json
{
  "department": {
    "billing": 0.95,
    "technical": 0.05
  },
  "urgency": {
    "3": 1.0
  },
  "outage": {
    "true": 0.98
  }
}
```
Decisions: Refund wrong plan 100% right
```json
{
  "department": {
    "billing": 0.95,
    "technical": 0.02,
    "account": 0.02,
    "shipping": 0.01,
    "sales": 0.00
  },
  "refund": {
    "true": 0.98,
    "false": 0.02
  },
  "tone": {
    "calm": 0.85,
    "frustrated": 0.15
  }
}
```
Decisions: Moderation doxxing 100% right
```json
{
  "policy": {
    "none": 0.0,
    "harassment": 0.95,
    "hate": 0.0,
    "spam": 0.0,
    "self_harm": 0.0
  },
  "personal_info": {
    "true": 0.95
  }
}
```
Decisions: Route calendar 100% right
```json
{
  "tool": {
    "calendar": 1.0,
    "web_search": 0.0,
    "calculator": 0.0,
    "email": 0.0,
    "none": 0.0
  },
  "confirm": {
    "true": 0.95,
    "false": 0.05
  }
}
```
Decisions: Doc invoice missing due 100% right
```json
{
  "doc_type": {
    "invoice": 0.98,
    "resume": 0.01,
    "contract": 0.005,
    "bank_statement": 0.005,
    "other": 0.00
  },
  "missing_due_date": {
    "true": 0.99
  }
}
```
Decisions: Phishing paypal 50% right
```json
{
  "phishing": {
    "true": 0.99,
    "false": 0.01
  },
  "risk": 3
}
```
Decisions: Pii ssn email 100% right
```json
{
  "data_kind": {
    "none": 0.0,
    "contact": 0.1,
    "financial": 0.0,
    "government_id": 0.9,
    "health": 0.0
  },
  "sensitive": {
    "true": 0.95,
    "false": 0.05
  }
}
```
Decisions: Review mixed 100% right
```json
{
  "sentiment": {
    "negative": 0.95,
    "positive": 0.02,
    "neutral": 0.03
  },
  "defect": {
    "true": 0.98,
    "false": 0.02
  },
  "recommend": {
    "true": 0.01,
    "false": 0.99
  }
}
```
Documents: Saas escalator (hard) 20% right
```json
{
  "year2_price_per_seat_month": 47.45,
  "year3_price_per_seat_month": 46.95,
  "year1_invoice": 5400.00,
  "year2_invoice": 5688.00,
  "addon_months_billed": 0,
  "addon_invoice": null,
  "year3_invoice": 6543.00,
  "year3_discount_percent": 15.0,
  "total_contract_value": 17631.00,
  "contract_end_date": "2027-02-28"
}
```
Documents: Expense thread 71% right
```json
{
  "employee_id": "EMP-20417",
  "destination_city": "Lisbon",
  "trip_start": "2025-02-24",
  "trip_end": "2025-02-27",
  "approved_items": [
    {
      "date": "2025-02-24",
      "category": "airfare",
      "amount_usd": 1184.60
    },
    {
      "date": "2025-02-24",
      "category": "ground_transport",
      "amount_usd": 38.72
    },
    {
      "date": "2025-02-25",
      "category": "meals",
      "amount_usd": 229.76
    },
    {
      "date": "2025-02-26",
      "category": "lodging",
      "amount_usd": 432.00
    },
    {
      "date": "2025-02-27",
      "category": "ground_transport",
      "amount_usd": 38.72
    }
  ],
  "rejected_item_count": 1,
  "per_diem_days": 2,
  "per_diem_usd": 130.00,
  "total_reimbursable_usd": 1984.28,
  "approver_email": "priya.raman@corvane.com"
}
```
Documents: Lease amendment 67% right
```json
{
  "tenants": ["Marcus Lin", "Sofia Lin"],
  "landlord": "Ridgeline Property Group LLC",
  "zip": "97205",
  "lease_end": "2025-11-30",
  "original_monthly_rent": 2150.0,
  "monthly_rent_from_2025_06_01": 2231.0,
  "late_fee_from_2025_06_01": 111.55,
  "security_deposit": 2150.0,
  "total_pet_deposits": 800.0,
  "total_monthly_payment_july_2025": 2315.0,
  "move_in_payment": 3050.0
}
```
Documents: Ticket SLA 82% right
```json
{
  "ticket_id": "48213",
  "account_id": "ACC-7731",
  "open_issue": "inventory_sync",
  "resolved_issues": ["billing_address"],
  "affected_orders": ["SO-99812", "SO-99820", "SO-99827"],
  "priority": "P2",
  "sla_due_local": "2025-09-12T17:00",
  "sla_due_utc": "2025-09-12T13:00:00Z",
  "reissued_invoice": "INV-2025-0812"
}
```
Documents: Sales footnotes 22% right
```json
{
  "q3_total_usd": 15572,
  "q2_total_usd": 15464,
  "q2_central_originally_reported_usd": 3047,
  "q2_to_q3_change_pct": 6.7,
  "top_region_q3": "East",
  "fastest_growing_region_q1_to_q3": "International",
  "regions_declining_q2_to_q3": ["Central"],
  "international_q3_organic_usd": 1731,
  "west_excluding_mountain_q3_usd": 4200
}
```

Size: 3.9B parameters. First tested OCT 10.

Models that scored about the same

Comments

Sign in with GitHub to comment. Spam and abuse are hidden automatically.