Review · updated OCT 11

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

It scored 44 out of 100, #35 of 56. It solved 13 of 30 coding jobs and scored 45 on reading documents. Runs on a 12 GB graphics card or a Mac with 16 GB.

The short version
  • Ministral 3 14B 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: 44 out of 100, #35 of 56.
  • It solved 13 of 30 coding jobs and scored 45 on reading documents. On our hardest tasks it scored 24.
  • Runs on a 12 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 14B 2512 got 13 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 14B 2512 scored 45; the best model scored 100.

TestScorePublic questionsSecret questions
Coding435739
Reading documents456241
Decisions868287

On the 18 hardest tasks (included in the scores above) it scored 24. 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 14B 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 GBNo–
Graphics card, 12 GBYesQ4
Graphics card, 16 GBYesQ6
Graphics card, 24 GBYesQ8
Graphics card, 32 GBYesQ8
Mac, 16 GBYesQ4
Mac, 24 GBYesQ6
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 14B 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 14B 2512 wrote for our public questions, word for word.

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

def parse_ini(text: str) -> Dict[str, Dict[str, str]]:
    lines = text.splitlines()
    sections: Dict[str, Dict[str, str]] = {}
    current_section: Optional[str] = None
    current_key: Optional[str] = None
    current_value_lines: List[str] = []
    default_section: Dict[str, str] = {}
    seen_sections = False

    # Pattern for section headers
    section_pattern = re.compile(r'^\s*\[([^\]]+)\]\s*$')
    # Pattern for key-value pairs (first = or :)
    kv_pattern = re.compile(r'^\s*([^=:\s]+)(?:[=:]|(?=\s*[;#]))')
    # Pattern for inline comments (after whitespace)
    comment_pattern = re.compile(r'^\s*(.*?)\s*(?:[;#].*)?$')
    # Pattern for quoted values
    quoted_value_pattern = re.compile(r'^"(.*)"$')
    # Pattern for interpolation
    interpolation_pattern = re.compile(r'\$\{([^:}]+)(?::([^}]+))?\}')

    def resolve_interpolation(value: str, section: str, all_sections: Dict[str, Dict[str, str]]) -> str:
        def resolve(match: re.Match) -> str:
            key = match.group(1)
            other_section = match.group(2)
            if other_section is None:
                # Same section interpolation
                if section not in all_sections:
                    raise KeyError(f"Section '{section}' not found")
                if key not in all_sections[section] and section != 'DEFAULT':
                    # Check DEFAULT section
                    if 'DEFAULT' in all_sections and key in all_sections['DEFAULT']:
                        return all_sections['DEFAULT'][key]
                    raise KeyError(f"Key '{key}' not found in section '{section}'")
                return all_sections[section][key]
            else:
                # Other section interpolation
                if other_section not in all_sections:
                    raise KeyError(f"Section '{other_section}' not found")
                if key not in all_sections[other_section]:
                    raise KeyError(f"Key '{key}' not found in section '{other_section}'")
                return all_sections[other_section][key]

        # Track visited sections to detect cycles
        visited = set()
        def resolve_recursive(value: str, section: str) -> str:
            if (section, value) in visited:
                raise ValueError("Interpolation cycle detected")
            visited.add((section, value))
            try:
                return interpolation_pattern.sub(resolve, value)
            finally:
                visited.remove((section, value))

        return resolve_recursive(value, section)

    for line_num, line in enumerate(lines, 1):
        stripped_line = line.strip()
        if not stripped_line or stripped_line.startswith((';', '#')):
            continue

        # Check for section header
        section_match = section_pattern.match(line)
        if section_match:
            section_name = section_match.group(1)
            if current_section is not None and current_key is not None:
                raise ValueError(f"Key line before section header at line {line_num}")
            if section_name == 'DEFAULT':
                current_section = None
                default_section = {}
            else:
                current_section = section_name
                if current_section not in sections:
                    sections[current_section] = {}
                    # Inherit from DEFAULT
                    if 'DEFAULT' in sections:
                        sections[current_section].update(sections['DEFAULT'])
                    else:
                        sections[current_section].update(default_section)
            seen_sections = True
            current_key = None
            current_value_lines = []
            continue

        # Check for key-value line
        if current_section is None and not seen_sections:
            raise ValueError(f"Key line before any section header at line {line_num}")

        # Check for continuation line
        if line.startswith((' ', '\t')):
            if current_key is None:
                raise ValueError(f"Continuation line without preceding key at line {line_num}")
            current_value_lines.append(line.strip())
            continue

        # Parse key-value pair
        kv_match = kv_pattern.match(line)
        if not kv_match:
            raise ValueError(f"Malformed line at line {line_num}")

        key = kv_match.group(1).strip().lower()
        rest = line[kv_match.end():].strip()

        # Handle inline comments
        comment_pos = -1
        for i, c in enumerate(rest):
            if c in (';', '#') and (i == 0 or rest[i-1].isspace()):
                comment_pos = i
                break
        if comment_pos != -1:
            rest = rest[:comment_pos].strip()

        # Handle quoted value
        if rest.startswith('"') and rest.endswith('"'):
            value = rest[1:-1]
            # Check for unescaped quotes inside (simple implementation)
            if value.count('"') % 2 != 0:
                raise ValueError(f"Unmatched quote in value at line {line_num}")
        else:
            # Split on first = or :
            value_parts = rest.split('=', 1)
            if len(value_parts) == 1:
                value_parts = rest.split(':', 1)
            if len(value_parts) != 2:
                raise ValueError(f"Missing value for key '{key}' at line {line_num}")
            value = value_parts[1].strip()

        # Handle continuation lines
        if current_key is not None:
            # This is a continuation of the previous key
            current_value_lines.append(value)
            value = '\n'.join(current_value_lines)
            current_value_lines = []
        else:
            current_key = key

        # Store the value
        if current_section is None:
            default_section[key] = value
        else:
            sections[current_section][key] = value

        current_key = None

    # Resolve all interpolations
    for section in list(sections.keys()):
        if section == 'DEFAULT':
            continue
        for key in list(sections[section].keys()):
            try:
                sections[section][key] = resolve_interpolation(sections[section][key], section, sections)
            except (KeyError, ValueError) as e:
                raise ValueError(f"Error resolving interpolation in section '{section}', key '{key}': {e}") from e

    # Remove DEFAULT section from output
    if 'DEFAULT' in sections:
        del sections['DEFAULT']

    return sections
```
Coding: TTL LRU (hard) Didn't work
```python
import collections
import time

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

    def _clean_expired(self):
        now = self.clock()
        expired_keys = []
        for key, (value, expiry) in self._cache.items():
            if now >= expiry:
                expired_keys.append(key)
        for key in expired_keys:
            del self._cache[key]
            self._stats["expirations"] += 1

    def _evict_if_needed(self):
        if self.capacity > 0 and len(self._cache) >= self.capacity:
            self._cache.popitem(last=False)
            self._stats["evictions"] += 1

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

        now = self.clock()
        expiry = now + self.ttl

        if key in self._cache:
            # Update existing entry
            self._cache.move_to_end(key)
            self._cache[key] = (value, expiry)
        else:
            # Insert new entry
            self._clean_expired()
            if len(self._cache) >= self.capacity:
                self._evict_if_needed()
            self._cache[key] = (value, expiry)

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

        now = self.clock()
        if key in self._cache:
            value, expiry = self._cache[key]
            if now < expiry:
                # Hit
                self._cache.move_to_end(key)
                self._stats["hits"] += 1
                return value
            else:
                # Expired
                del self._cache[key]
                self._stats["expirations"] += 1
        else:
            # Miss
            self._stats["misses"] += 1
        return default

    def __len__(self):
        now = self.clock()
        return sum(1 for (_, expiry) in self._cache.values() if now < expiry)

    def stats(self):
        return self._stats.copy()
```
Coding: Parse duration Didn't work
```python
def parse_duration(s: str) -> int:
    if not s.strip():
        raise ValueError("Empty or blank string")

    parts = []
    current_part = []
    i = 0
    n = len(s)

    while i < n:
        c = s[i]
        if c.isdigit() or c == '.':
            current_part.append(c)
            i += 1
        elif c in 'hmsHMS':
            if not current_part:
                raise ValueError("Number without a unit")
            if '.' in current_part:
                raise ValueError("Decimals not allowed")
            parts.append((''.join(current_part), c.lower()))
            current_part = []
            i += 1
        elif c.isspace():
            i += 1
        else:
            raise ValueError(f"Unknown character: {c}")

    if current_part:
        raise ValueError("Number without a unit")

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

    units = []
    values = []
    for value, unit in parts:
        try:
            num = int(value)
        except ValueError:
            raise ValueError(f"Invalid number: {value}")

        if unit in units:
            raise ValueError(f"Repeated unit: {unit}")
        units.append(unit)
        values.append(num)

    if units != ['h', 'm', 's']:
        raise ValueError("Units must appear in order h, m, s")

    total_seconds = 0
    for i, unit in enumerate(units):
        if unit == 'h':
            total_seconds += values[i] * 3600
        elif unit == 'm':
            total_seconds += values[i] * 60
        elif unit == 's':
            total_seconds += values[i]

    return total_seconds
```
Coding: Summarize ranges Worked
```python
def summarize_ranges(nums: list[int]) -> str:
    if not nums:
        return ""

    unique_sorted = sorted(set(nums))
    result = []
    start = unique_sorted[0]
    prev = start

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

    # Handle the last range
    if start == prev:
        result.append(str(start))
    elif prev == start + 1:
        result.append(f"{start},{prev}")
    else:
        result.append(f"{start}..{prev}")

    return ",".join(result)
```
Coding: Top customers Worked
```python
import re
from collections import defaultdict

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

    for row in rows:
        customer = row.get("customer", "").strip()
        amount_str = row.get("amount", "")

        if not customer or not amount_str:
            continue

        # Parse amount
        try:
            # Handle different amount formats
            amount_str = amount_str.replace("$", "").replace(",", "")
            if amount_str.startswith("-"):
                amount = -float(amount_str[1:])
            else:
                amount = float(amount_str)
        except ValueError:
            continue

        # Normalize customer name (case-insensitive, first valid occurrence)
        normalized_customer = customer.lower()
        if normalized_customer not in seen_customers:
            seen_customers.add(normalized_customer)
            # Store the original trimmed name for the first occurrence
            customer_totals[customer] = amount
        else:
            # Add to existing customer's total
            for name in customer_totals:
                if name.lower() == normalized_customer:
                    customer_totals[name] += amount
                    break

    # Prepare list of (name, total) tuples
    result = []
    for name, total in customer_totals.items():
        rounded_total = round(total, 2)
        # Handle cases where rounding to 2 decimals might give .9999999999999999
        rounded_total = float(f"{rounded_total:.2f}")
        result.append((name, rounded_total))

    # Sort by total descending, then name ascending
    result.sort(key=lambda x: (-x[1], x[0]))

    # Return top n
    return result[:n]
```
Coding: Slugify Worked
```python
import unicodedata
import re

def slugify(title: str, max_len: int = 50) -> str:
    # Step 1: Transliterate accented characters and drop non-ASCII
    normalized = unicodedata.normalize('NFKD', title)
    ascii_only = []
    for c in normalized:
        if not unicodedata.combining(c):
            try:
                ascii_only.append(c.encode('ascii', 'ignore').decode('ascii'))
            except:
                pass
    ascii_str = ''.join(ascii_only)

    # Step 2: Lowercase
    lower_str = ascii_str.lower()

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

    # Step 4: Handle max_len constraint
    if not slug:
        return ''

    words = slug.split('-')
    if len(words) == 1:
        if len(slug) > max_len:
            return slug[:max_len]
        return slug

    total_len = len(slug)
    if total_len <= max_len:
        return slug

    # Find the longest prefix of whole words <= max_len
    prefix = []
    current_len = 0
    for word in words:
        word_len = len(word)
        if current_len + word_len + len(prefix) > max_len:
            break
        prefix.append(word)
        current_len += word_len

    if not prefix:
        return words[0][:max_len]

    result = '-'.join(prefix)
    if len(result) > max_len:
        # This shouldn't happen due to the logic above, but just in case
        return result[:max_len]
    return result
```
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) -> None:
        now = self._clock()
        elapsed = now - self._last_refill_time
        self._last_refill_time = now
        self._tokens = min(self._capacity, self._tokens + elapsed * self._refill_per_sec)

    @property
    def tokens(self) -> float:
        self._refill()
        return self._tokens

    def allow(self, cost: float = 1) -> bool:
        self._refill()
        if self._tokens >= cost:
            self._tokens -= cost
            return True
        return False
```
Decisions: Refund window (hard) 50% right
```json
{
  "outcome": {
    "partial_refund": 0.95,
    "full_refund": 0.03,
    "store_credit": 0.01,
    "nothing": 0.01
  },
  "defective": {
    "false": 0.99,
    "true": 0.01
  }
}
```
Decisions: Incident matrix (hard) 0% right
```json
{
  "severity": {
    "2": 0.85,
    "3": 0.15
  },
  "page": {
    "true": 0.85,
    "false": 0.15
  }
}
```
Decisions: Tool followup (hard) 100% right
```json
{
  "tool": {
    "calendar": 0.95,
    "none": 0.05,
    "web_search": 0.0,
    "calculator": 0.0,
    "email": 0.0
  },
  "confirm": {
    "true": 0.99,
    "false": 0.01
  }
}
```
Decisions: Legit security alert (hard) 100% right
```json
{
  "phishing": {
    "true": 0.01,
    "false": 0.99
  },
  "action_needed": {
    "true": 0.05,
    "false": 0.95
  }
}
```
Decisions: Meeting slot (hard) 0% right
```json
{
  "slot": {
    "A": 0.0,
    "B": 0.0,
    "C": 1.0,
    "D": 0.0
  },
  "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.99,
    "false": 0.01
  }
}
```
Decisions: Support checkout down 100% right
```json
{
  "department": {
    "billing": 0.05,
    "technical": 0.9,
    "account": 0.01,
    "shipping": 0.01,
    "sales": 0.03
  },
  "urgency": {
    "0": 0.01,
    "1": 0.02,
    "2": 0.05,
    "3": 0.92
  },
  "outage": {
    "true": 0.99,
    "false": 0.01
  }
}
```
Decisions: Refund wrong plan 100% right
```json
{
  "department": {
    "billing": 0.95,
    "technical": 0.01,
    "account": 0.01,
    "shipping": 0.00,
    "sales": 0.03
  },
  "refund": {
    "true": 0.98,
    "false": 0.02
  },
  "tone": {
    "frustrated": 0.05,
    "calm": 0.95
  }
}
```
Decisions: Moderation doxxing 100% right
```json
{
  "policy": {
    "none": 0.05,
    "harassment": 0.90,
    "hate": 0.01,
    "spam": 0.01,
    "self_harm": 0.03
  },
  "personal_info": {
    "true": 0.95,
    "false": 0.05
  }
}
```
Decisions: Route calendar 100% right
```json
{
  "tool": {
    "web_search": 0.05,
    "calculator": 0.01,
    "calendar": 0.85,
    "email": 0.05,
    "none": 0.04
  },
  "confirm": {
    "true": 0.95,
    "false": 0.05
  }
}
```
Decisions: Doc invoice missing due 100% right
```json
{
  "doc_type": {
    "invoice": 0.999,
    "resume": 0.0,
    "contract": 0.001,
    "bank_statement": 0.0,
    "other": 0.0
  },
  "missing_due_date": {
    "true": 0.999,
    "false": 0.001
  }
}
```
Decisions: Phishing paypal 100% right
```json
{
  "phishing": {
    "true": 0.999,
    "false": 0.001
  },
  "risk": {
    "0": 0.0001,
    "1": 0.0005,
    "2": 0.0004,
    "3": 0.999
  }
}
```
Decisions: Pii ssn email 100% right
```json
{
  "data_kind": {
    "none": 0.0,
    "contact": 0.2,
    "financial": 0.0,
    "government_id": 0.75,
    "health": 0.05
  },
  "sensitive": {
    "true": 0.8,
    "false": 0.2
  }
}
```
Decisions: Review mixed 100% right
```json
{
  "sentiment": {
    "positive": 0.05,
    "neutral": 0.05,
    "negative": 0.9
  },
  "defect": {
    "true": 0.95,
    "false": 0.05
  },
  "recommend": {
    "true": 0.05,
    "false": 0.95
  }
}
```
Documents: Saas escalator (hard) 30% right
```json
{
  "year2_price_per_seat_month": 47.97,
  "year3_price_per_seat_month": 47.97,
  "year1_invoice": 51840,
  "year2_invoice": 57534.4,
  "addon_months_billed": 6,
  "addon_invoice": 4678.4,
  "year3_invoice": 57534.4,
  "year3_discount_percent": 15,
  "total_contract_value": 119162.8,
  "contract_end_date": "2027-02-28"
}
```
Documents: Expense thread 75% 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.88
    },
    {
      "date": "2025-02-25",
      "category": "meals",
      "amount_usd": 229.23
    },
    {
      "date": "2025-02-26",
      "category": "lodging",
      "amount_usd": 466.56
    },
    {
      "date": "2025-02-27",
      "category": "ground_transport",
      "amount_usd": 44.73
    }
  ],
  "rejected_item_count": 2,
  "per_diem_days": 2,
  "per_diem_usd": 130.00,
  "total_reimbursable_usd": 2053.90,
  "approver_email": "priya.raman@corvane.com"
}
```
Documents: Lease amendment 100% right
```json
{
  "tenants": ["Marcus Lin", "Sofia Lin"],
  "landlord": "Ridgeline Property Group LLC",
  "zip": "97205",
  "lease_end": "2025-11-30",
  "original_monthly_rent": 2150,
  "monthly_rent_from_2025_06_01": 2236,
  "late_fee_from_2025_06_01": 111.8,
  "security_deposit": 2150,
  "total_pet_deposits": 800,
  "total_monthly_payment_july_2025": 2306,
  "move_in_payment": 4700
}
```
Documents: Ticket SLA 73% 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": "P1",
  "sla_due_local": "2025-09-13T09:00",
  "sla_due_utc": "2025-09-13T14:00:00Z",
  "reissued_invoice": "INV-2025-0812"
}
```
Documents: Sales footnotes 30% right
```json
{
  "q3_total_usd": 15246,
  "q2_total_usd": 14464,
  "q2_central_originally_reported_usd": 3047,
  "q2_to_q3_change_pct": 5.4,
  "top_region_q3": "East",
  "fastest_growing_region_q1_to_q3": "International",
  "regions_declining_q2_to_q3": [
    "East",
    "Central"
  ],
  "international_q3_organic_usd": 1731,
  "west_excluding_mountain_q3_usd": 4201
}
```

Size: 14B parameters. First tested OCT 10.

Models that scored about the same

Comments

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