Review · updated OCT 11

Nemotron 3 Nano 30B A3B review: a solid all-rounder

It scored 80 out of 100, #13 of 56. It solved 21 of 30 coding jobs and scored 91 on reading documents. Runs on a 24 GB graphics card or a Mac with 32 GB.

The short version
  • Nemotron 3 Nano 30B A3B is a free model from NVIDIA that you can run on your own computer. In our tests it's a solid all-rounder: 80 out of 100, #13 of 56.
  • It solved 21 of 30 coding jobs and scored 91 on reading documents. On our hardest tasks it scored 69.
  • Runs on a 24 GB graphics card or a Mac with 32 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. Nemotron 3 Nano 30B A3B got 21 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. Nemotron 3 Nano 30B A3B scored 91; the best model scored 100.

TestScorePublic questionsSecret questions
Coding708665
Reading documents919290
Decisions949693

On the 18 hardest tasks (included in the scores above) it scored 69. 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 Nemotron 3 Nano 30B A3B 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 GBNo–
Graphics card, 16 GBNo–
Graphics card, 24 GBYesQ5
Graphics card, 32 GBYesQ6
Mac, 16 GBNo–
Mac, 24 GBNo–
Mac, 32 GBYesQ4
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 "Nemotron 3 Nano 30B A3B 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 Nemotron 3 Nano 30B A3B wrote for our public questions, word for word.

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

def parse_ini(text: str) -> Dict[str, Dict[str, str]]:
    sections: Dict[str, Dict[str, str]] = {}
    default_section: Dict[str, str] = {}
    mode: str | None = None
    current_dict: Dict[str, str] | None = None
    current_key: str | None = None
    value_parts: list[str] = []
    lines = text.splitlines()
    for line in lines:
        raw_line = line.rstrip('\n')
        if not raw_line.strip():
            continue
        if raw_line.lstrip().startswith((';', '#')):
            continue
        # strip inline comment (whitespace followed by ; or #)
        in_quotes = False
        i = 0
        while i < len(raw_line):
            ch = raw_line[i]
            if ch == '"' and (i == 0 or raw_line[i-1] != '\\'):
                in_quotes = not in_quotes
            if i > 0 and raw_line[i] in ';#' and raw_line[i-1].isspace() and not in_quotes:
                raw_line = raw_line[:i].rstrip()
                break
            i += 1
        if current_key is not None and raw_line and raw_line[0].isspace():
            cont = raw_line.strip()
            value_parts.append('\n' + cont)
            continue
        if raw_line.startswith('[') and raw_line.endswith(']'):
            if current_key is not None:
                current_dict[current_key] = ''.join(value_parts) if value_parts else ''
                current_key = None
                value_parts = []
            sec_name = raw_line[1:-1].strip()
            if sec_name == 'DEFAULT':
                mode = 'DEFAULT'
                current_dict = default_section
            else:
                mode = sec_name
                if sec_name not in sections:
                    sections[sec_name] = {}
                current_dict = sections[sec_name]
            continue
        if mode is None:
            raise ValueError('key before any section')
        eq_idx = raw_line.find('=')
        colon_idx = raw_line.find(':')
        split_idx = -1
        if eq_idx != -1 and colon_idx != -1:
            split_idx = min(eq_idx, colon_idx)
        elif eq_idx != -1:
            split_idx = eq_idx
        elif colon_idx != -1:
            split_idx = colon_idx
        else:
            raise ValueError(f'malformed line: {raw_line!r}')
        key = raw_line[:split_idx].strip().lower()
        val_raw = raw_line[split_idx+1:].strip()
        if val_raw.startswith('"') and val_raw.endswith('"'):
            val = val_raw[1:-1]
        else:
            val = val_raw
        if current_key is not None:
            current_dict[current_key] = ''.join(value_parts) if value_parts else ''
        current_key = key
        value_parts = [val]
        continue
    if current_key is not None:
        if mode is None:
            raise ValueError('key before any section')
        current_dict[current_key] = ''.join(value_parts) if value_parts else ''
        current_key = None
        value_parts = []

    # ---------- interpolation resolution ----------
    def resolve_text(txt: str, src_section: str | None, visited: set) -> str:
        while True:
            idx = txt.find('${')
            if idx == -1:
                return txt
            start = idx + 2
            end = txt.find('}', start)
            if end == -1:
                return txt
            inner = txt[idx+2:end]

            # lookup raw value
            if src_section is not None and inner in sections.get(src_section, {}):
                raw_val = sections[src_section][inner]
                new_src = src_section
            elif inner in default_section:
                raw_val = default_section[inner]
                new_src = None
            elif ':' in inner:
                sec, sub = inner.split(':', 1)
                if sec == 'DEFAULT':
                    raw_val = default_section.get(sub)
                    if raw_val is None:
                        raise KeyError(f'default key {sub!r} not found')
                    new_src = None
                else:
                    sec_dict = sections.get(sec)
                    if sec_dict is None or sub not in sec_dict:
                        raise KeyError(f'key {sub!r} not found in section {sec!r}')
                    raw_val = sec_dict[sub]
                    new_src = sec
            else:
                raise KeyError(f'key {inner!r} not found')

            if inner in visited:
                raise ValueError('reference cycle')
            visited.add(inner)
            resolved_raw = resolve_text(raw_val, new_src, visited)
            visited.remove(inner)

            txt = txt[:idx] + resolved_raw + txt[end+1:]

    def resolve_value(v: str, src_section: str | None) -> str:
        return resolve_text(v, src_section, set())

    # resolve all section values
    for sec_name, sec_dict in list(sections.items()):
        for k, v in list(sec_dict.items()):
            sec_dict[k] = resolve_value(v, sec_name)

    # resolve default_section values
    for k, v in list(default_section.items()):
        default_section[k] = resolve_value(v, None)

    return sections
```
Coding: TTL LRU (hard) Worked
```python
from collections import OrderedDict
from typing import Callable, Dict, Any

class TTLCache:
    def __init__(self, capacity: int, ttl: float, clock: Callable[[], float]):
        self.capacity = capacity
        self.ttl = ttl
        self._clock = clock
        self._data: OrderedDict = OrderedDict()
        self._stats = {"hits": 0, "misses": 0, "evictions": 0, "expirations": 0}

    # --------------------------------------------------------------------- #
    # internal helpers
    # --------------------------------------------------------------------- #
    def _now(self) -> float:
        return self._clock()

    def _purge_expired(self) -> None:
        now = self._now()
        expired_keys = [k for k, (_, exp) in self._data.items() if now >= exp]
        for k in expired_keys:
            del self._data[k]
            self._stats["expirations"] += 1

    def _remove_expired_entry(self, key: Any) -> None:
        if key in self._data:
            del self._data[key]
            self._stats["expirations"] += 1

    # --------------------------------------------------------------------- #
    # public API
    # --------------------------------------------------------------------- #
    def get(self, key: Any, default: Any = None) -> Any:
        now = self._now()
        if key not in self._data:
            self._stats["misses"] += 1
            return default
        value, expires = self._data[key]
        if now >= expires:
            # expired entry
            del self._data[key]
            self._stats["expirations"] += 1
            self._stats["misses"] += 1
            return default
        # cache hit
        self._stats["hits"] += 1
        self._data.move_to_end(key)  # mark most recent
        return value

    def put(self, key: Any, value: Any) -> None:
        if self.capacity <= 0:
            return
        now = self._now()
        if key in self._data:
            # replace existing entry
            _, _ = self._data.pop(key)
            self._data[key] = (value, now + self.ttl)
            self._data.move_to_end(key)
            return

        # inserting a new key
        self._purge_expired()
        while len(self._data) >= self.capacity:
            _, _ = self._data.popitem(last=False)
            self._stats["evictions"] += 1
        self._data[key] = (value, now + self.ttl)
        self._data.move_to_end(key)  # newly inserted is most recent

    def __len__(self) -> int:
        now = self._now()
        return sum(1 for _, exp in self._data.values() if exp > now)

    def stats(self) -> Dict[str, int]:
        return {
            "hits": self._stats["hits"],
            "misses": self._stats["misses"],
            "evictions": self._stats["evictions"],
            "expirations": self._stats["expirations"],
        }
```
Coding: Parse duration Worked
```python
import re

def parse_duration(s: str) -> int:
    """
    Convert a duration string to total seconds.

    Supported units (case‑insensitive): h (hours), m (minutes), s (seconds).
    Parts must appear in the order h, then m, then s, each at most once.
    Whitespace is ignored. Raises ValueError for invalid input.
    """
    if not s or s.strip() == '':
        raise ValueError("empty string")

    # Pattern: optional h part, then optional m part, then optional s part.
    # Each part: one or more digits followed by its unit, optional surrounding whitespace.
    pattern = re.compile(
        r'^\s*'
        r'(?:\s*(\d+)\s*[hH]\s*)?'   # 0 or 1 hour component
        r'(?:\s*(\d+)\s*[mM]\s*)?'   # 0 or 1 minute component
        r'(?:\s*(\d+)\s*[sS]\s*)?'   # 0 or 1 second component
        r'\s*$',
        re.IGNORECASE
    )

    m = pattern.fullmatch(s)
    if not m:
        raise ValueError("invalid duration format")

    hours = int(m.group(1)) if m.group(1) else 0
    minutes = int(m.group(2)) if m.group(2) else 0
    seconds = int(m.group(3)) if m.group(3) else 0

    return hours * 3600 + minutes * 60 + seconds
```
Coding: Summarize ranges Worked
```python
def summarize_ranges(nums: list[int]) -> str:
    if not nums:
        return ""
    uniq = sorted(set(nums))
    parts = []
    i = 0
    n = len(uniq)
    while i < n:
        j = i
        while j + 1 < n and uniq[j + 1] == uniq[j] + 1:
            j += 1
        length = j - i + 1
        if length >= 3:
            parts.append(f"{uniq[i]}..{uniq[j]}")
        else:
            for k in range(i, j + 1):
                parts.append(str(uniq[k]))
        i = j + 1
    return ",".join(parts)
```
Coding: Top customers Worked
```python
def top_customers(rows: list[dict], n: int) -> list[tuple[str, float]]:
    """
    Return the top `n` customers by summed amount.
    Each row is expected to have keys "customer" and "amount".
    - Customer names are trimmed and compared case‑insensitively.
    - The displayed name is the trimmed version of the first valid row for that customer.
    - Amount strings may be like "$1,234.50", "1234.5", "-$5.00", "$1,000".
    - Rows with empty customer or unparsable amount are ignored.
    - Totals are rounded to two decimal places.
    - Result is sorted by total descending, then name ascending.
    """
    import re

    def parse_amount(s: str) -> float | None:
        """Convert a formatted amount string to a float, or return None on failure."""
        s = s.strip()
        if not s:
            return None

        sign = 1
        if s.startswith("-"):
            sign = -1
            s = s[1:]

        # Remove commas and any leading currency symbols
        s = s.replace(",", "")
        s = s.lstrip("$")

        try:
            return sign * float(s)
        except ValueError:
            return None

    # Mapping from lower‑cased trimmed name -> (display_name, accumulated_total)
    totals: dict[str, tuple[str, float]] = {}

    for row in rows:
        # Extract and trim customer name
        raw_customer = row.get("customer", "")
        cust_trimmed = raw_customer.strip()
        if not cust_trimmed:
            continue

        # Extract and parse amount
        raw_amount = row.get("amount", "")
        amount_val = parse_amount(raw_amount)
        if amount_val is None:
            continue

        # Normalise key for case‑insensitive matching
        key = cust_trimmed.lower()
        if key not in totals:
            # First time we see this customer – keep the trimmed spelling
            totals[key] = (cust_trimmed, 0.0)

        display_name, current_total = totals[key]
        totals[key] = (display_name, current_total + amount_val)

    # Build list of (name, rounded_total) and sort
    result = [(info[0], round(info[1], 2)) for info in totals.values()]
    result.sort(key=lambda x: (-x[1], x[0]))

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


def slugify(title: str, max_len: int = 50) -> str:
    """
    Convert a title into a URL slug following the rules described.
    """
    # 1. Transliterate accented characters and drop other non‑ASCII.
    slug = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")

    # 2. Lowercase.
    slug = slug.lower()

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

    # 4. Shorten if necessary, preserving whole words.
    if len(slug) <= max_len:
        return slug

    words = slug.split("-")
    parts: list[str] = []
    cur_len = 0

    for w in words:
        # Length added by this word (including a preceding hyphen if not first)
        added = len(w) + (1 if parts else 0)
        if cur_len + added <= max_len:
            parts.append(w)
            cur_len += added
        else:
            break

    if not parts:  # first word already longer than max_len
        result = slug[:max_len]
    else:
        result = "-".join(parts)

    # Ensure the slug never ends with a hyphen.
    return result.rstrip("-")
```
Coding: Token bucket Worked
```python
from typing import Callable


class TokenBucket:
    def __init__(self, capacity: float, refill_per_sec: float, clock: Callable[[], float]) -> None:
        self.capacity = capacity
        self.refill_per_sec = refill_per_sec
        self.clock = clock
        self._tokens = capacity
        self._last_refill = clock()

    def _refill(self) -> None:
        now = self.clock()
        elapsed = now - self._last_refill
        added = elapsed * self.refill_per_sec
        self._tokens = min(self.capacity, self._tokens + added)
        self._last_refill = now

    def allow(self, cost: float = 1.0) -> 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) 100% right
{
  "outcome": {
    "full_refund": 0.0,
    "partial_refund": 0.05,
    "store_credit": 0.9,
    "nothing": 0.05
  },
  "defective": {
    "true": 0.0,
    "false": 1.0
  }
}
Decisions: Incident matrix (hard) 100% right
{
  "severity": {
    "0": 0.005,
    "1": 0.98,
    "2": 0.005,
    "3": 0.01
  },
  "page": {
    "true": 0.01,
    "false": 0.99
  }
}
Decisions: Tool followup (hard) 100% right
{
  "tool": {
    "web_search": 0.0,
    "calculator": 0.0,
    "calendar": 0.95,
    "email": 0.05,
    "none": 0.0
  },
  "confirm": {
    "true": 0.8,
    "false": 0.2
  }
}
Decisions: Legit security alert (hard) 100% right
{
  "phishing": {
    "true": 0.1,
    "false": 0.9
  },
  "action_needed": {
    "true": 0.05,
    "false": 0.95
  }
}
Decisions: Meeting slot (hard) 100% right
{
  "slot": {
    "A": 0,
    "B": 1,
    "C": 0,
    "D": 0
  },
  "raj_last": {
    "true": 1,
    "false": 0
  }
}
Decisions: Review mixed (hard) 100% right
{
  "hardware": {
    "true": 0.96,
    "false": 0.04
  },
  "support": {
    "true": 0.98,
    "false": 0.02
  }
}
Decisions: Support checkout down 100% right
{
  "department": {
    "billing": 0.02,
    "technical": 0.95,
    "account": 0.01,
    "shipping": 0.01,
    "sales": 0.01
  },
  "urgency": {
    "0": 0.0,
    "1": 0.0,
    "2": 0.0,
    "3": 1.0
  },
  "outage": {
    "true": 1.0,
    "false": 0.0
  }
}
Decisions: Refund wrong plan 100% right
{
  "department": {
    "billing": 0.95,
    "technical": 0.02,
    "account": 0.01,
    "shipping": 0.01,
    "sales": 0.01
  },
  "refund": {
    "true": 1.0,
    "false": 0.0
  },
  "tone": {
    "calm": 1.0,
    "frustrated": 0.0
  }
}
Decisions: Moderation doxxing 100% right
{
  "policy": {
    "none": 0.1,
    "harassment": 0.85,
    "hate": 0.02,
    "spam": 0.02,
    "self_harm": 0.01
  },
  "personal_info": {
    "true": 0.99,
    "false": 0.01
  }
}
Decisions: Route calendar 100% right
{
  "tool": {
    "web_search": 0.07,
    "calculator": 0.0,
    "calendar": 0.86,
    "email": 0.04,
    "none": 0.03
  },
  "confirm": {
    "true": 0.96,
    "false": 0.04
  }
}
Decisions: Doc invoice missing due 50% right
{
  "doc_type": {
    "invoice": 0.98,
    "resume": 0.01,
    "contract": 0.005,
    "bank_statement": 0.002,
    "other": 0.003
  },
  "missing_due_date": {
    "true": 0.0,
    "false": 1.0
  }
}
Decisions: Phishing paypal 100% right
{
  "phishing": {
    "true": 0.96,
    "false": 0.04
  },
  "risk": {
    "0": 0.02,
    "1": 0.03,
    "2": 0.05,
    "3": 0.90
  }
}
Decisions: Pii ssn email 100% right
{
  "data_kind": {
    "none": 0.02,
    "contact": 0.10,
    "financial": 0.15,
    "government_id": 0.70,
    "health": 0.03
  },
  "sensitive": {
    "true": 0.95,
    "false": 0.05
  }
}
Decisions: Review mixed 100% right
{
  "sentiment": {
    "positive": 0.05,
    "neutral": 0.05,
    "negative": 0.9
  },
  "defect": {
    "true": 0.99,
    "false": 0.01
  },
  "recommend": {
    "true": 0.0,
    "false": 1.0
  }
}
Documents: Saas escalator (hard) 100% right
{
  "year2_price_per_seat_month": 47.25,
  "year3_price_per_seat_month": 47.25,
  "year1_invoice": 58320,
  "year2_invoice": 61236,
  "addon_months_billed": 6,
  "addon_invoice": 38556,
  "year3_invoice": 134946,
  "year3_discount_percent": 15,
  "total_contract_value": 293058,
  "contract_end_date": "2027-02-28"
}
Documents: Expense thread 92% right
{
  "employee_id": null,
  "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.39
    },
    {
      "date": "2025-02-26",
      "category": "lodging",
      "amount_usd": 466.56
    },
    {
      "date": "2025-02-27",
      "category": "ground_transport",
      "amount_usd": 44.82
    }
  ],
  "rejected_item_count": 1,
  "per_diem_days": 3,
  "per_diem_usd": 65,
  "total_reimbursable_usd": 2159.25,
  "approver_email": "priya.raman@corvane.com"
}
Documents: Lease amendment 100% right
{
  "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.00,
  "late_fee_from_2025_06_01": 111.80,
  "security_deposit": 2150.00,
  "total_pet_deposits": 800.00,
  "total_monthly_payment_july_2025": 2306.00,
  "move_in_payment": 4700.00
}
Documents: Ticket SLA 67% right
{
  "ticket_id": "48213",
  "account_id": "ACC-7731",
  "open_issue": "inventory_sync",
  "resolved_issues": [
    "billing_address",
    "invoice_pdf"
  ],
  "affected_orders": [
    "SO-99827"
  ],
  "priority": "P2",
  "sla_due_local": "2025-09-15T15:30",
  "sla_due_utc": "2025-09-15T20:30:00Z",
  "reissued_invoice": "INV-2025-0812"
}
Documents: Sales footnotes 100% right
{
  "q3_total_usd": 15346000,
  "q2_total_usd": 14464000,
  "q2_central_originally_reported_usd": 3047000,
  "q2_to_q3_change_pct": 6.1,
  "top_region_q3": "East",
  "fastest_growing_region_q1_to_q3": "International",
  "regions_declining_q2_to_q3": [
    "East"
  ],
  "international_q3_organic_usd": 1731000,
  "west_excluding_mountain_q3_usd": 4201000
}

Size: 32B parameters. First tested OCT 10.

Models that scored about the same

Comments

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