Review · updated OCT 11

Granite 4.0 Micro (Q4, Mac) review: not one we'd recommend right now

It scored 22 out of 100, #49 of 56. It solved 4 of 30 coding jobs and scored 31 on reading documents. Runs on an 8 GB graphics card or a Mac with 16 GB.

The short version
  • Granite 4.0 Micro (Q4, Mac) is a free model from IBM that you can run on your own computer. In our tests it's not one we'd recommend right now: 22 out of 100, #49 of 56.
  • It solved 4 of 30 coding jobs and scored 31 on reading documents. On our hardest tasks it scored 14.
  • 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. Granite 4.0 Micro (Q4, Mac) got 4 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. Granite 4.0 Micro (Q4, Mac) scored 31; the best model scored 100.

TestScorePublic questionsSecret questions
Coding13017
Reading documents314128
Decisions???

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

We tested the Q4 download, the version most people run, with Ollama on our Mac mini (M4, 16 GB).

Can your computer run it?

Granite 4.0 Micro (Q4, Mac) comes in one standard size, which needs about 3.2 GB of memory.

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

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

How to try it

The easiest way is Ollama. Install it, then run:

ollama run granite4:micro-h

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

Its actual answers

What Granite 4.0 Micro (Q4, Mac) wrote for our public questions, word for word.

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

def parse_ini(text: str) -> Dict[str, Dict[str, str]]:
    def resolve_key(section: str, key: str) -> str:
        parts = key.split(':', 1)
        if len(parts) == 1:
            return sections.get(section, {}).get(key)
        else:
            sec, k = parts
            return resolve_key(sec, k)

    lines = text.splitlines()
    sections = {}
    current_section = 'DEFAULT'
    result = {}

    key_pattern = re.compile(r'^\s*([\w-]+)\s*(?:=[^=]+|:)\s*$', re.IGNORECASE)
    comment_pattern = re.compile(r'^\s*;.*|^\s*#\s*.*$', re.IGNORECASE)

    i = 0
    while i < len(lines):
        line = lines[i].strip()
        if not line or comment_pattern.match(line):
            i += 1
            continue

        if line.startswith('[') and line.endswith(']'):
            sec_name = line[1:-1]
            if sec_name == '':
                raise ValueError("Empty section name")
            current_section = sec_name
            result[current_section] = {}
            i += 1
            continue

        match = key_pattern.match(line)
        if not match:
            raise ValueError("Malformed line")

        key, raw_value = match.groups()
        value = raw_value[1:].rstrip() if value[0] in ('"', "'") else raw_value

        if current_section not in result:
            result[current_section] = {}

        result[current_section][key.lower()] = value
        i += 1

    # Handle interpolation
    def resolve_key_recursive(section: str, key: str) -> str:
        nonlocal result
        value = resolve_key(section, key)
        while '${' in value:
            value = re.sub(r'\$\{([^}]+)\}', lambda m: resolve_key_recursive(section, m.group(1)), value)
        return value

    for sec, keys in result.items():
        for k in keys:
            result[sec][k] = resolve_key_recursive(sec, k)

    return result
```
Coding: TTL LRU (hard) Didn't work
```python
from collections import OrderedDict
from typing import Callable, Dict, Any, Optional
import time

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

    def _evict_expired(self) -> None:
        now = self.clock()
        while self.cache and now >= self.cache[next(iter(self.cache))]["expiry"]:
            self.cache.popitem(last=False)
            self.stats["expirations"] += 1

    def put(self, key: Any, value: Any) -> None:
        now = self.clock()
        if key in self.cache:
            self.cache.move_to_end(key)
            self.cache[key]["value"] = value
            self.cache[key]["expiry"] = now + self.ttl
            self.stats["hits"] += 1
        else:
            if len(self.cache) >= self.capacity:
                self._evict_expired()
                if len(self.cache) >= self.capacity:
                    self.stats["evictions"] += 1
                    self.cache.popitem(last=False)
            self.cache[key] = {"value": value, "expiry": now + self.ttl}
            self.stats["hits"] += 1

    def get(self, key: Any, default: Optional[Any] = None) -> Any:
        now = self.clock()
        if key not in self.cache:
            self.stats["misses"] += 1
            return default
        if now >= self.cache[key]["expiry"]:
            self.cache.pop(key)
            self.stats["expirations"] += 1
            return default
        self.cache.move_to_end(key)
        self.stats["hits"] += 1
        return self.cache[key]["value"]

    def __len__(self) -> int:
        self._evict_expired()
        return len(self.cache)

    def stats(self) -> Dict[str, int]:
        return self.stats
```
Coding: Parse duration Didn't work
```python
def parse_duration(s: str) -> int:
    """
    Convert a duration string into a total number of seconds.
    
    Supported units: 'h' (hours), 'm' (minutes), 's' (seconds).
    Units are case-insensitive. Each part is a non-negative integer 
    followed by a unit, separated by optional whitespace.
    
    Raises ValueError for:
    - Empty/blank strings
    - Numbers without a unit
    - Unknown units
    - Decimals
    - Repeated units
    - Units out of order (e.g., 'ms')
    """
    if not s.strip():
        raise ValueError("Empty string provided")

    total_seconds = 0
    parts = s.split()
    seen_units = set()

    for part in parts:
        # Check for decimals
        if '.' in part:
            raise ValueError("Decimals are not supported")

        # Extract numeric part and unit
        num_str = ''
        unit = ''
        for char in part:
            if char.isdigit():
                num_str += char
            elif char.upper() in 'HMS':
                unit += char.upper()
            else:
                raise ValueError(f"Unknown unit '{char}' in '{part}'")

        # Validate parts
        if not num_str:
            raise ValueError(f"Number missing in '{part}'")
        if not unit:
            raise ValueError(f"Unit missing in '{part}'")
        if unit not in 'HMS':
            raise ValueError(f"Unknown unit '{unit}' in '{part}'")
        if unit in seen_units:
            raise ValueError(f"Duplicate unit '{unit}' in '{part}'")
        seen_units.add(unit)

        # Convert and accumulate
        total_seconds += int(num_str) * {'H': 3600, 'M': 60, 'S': 1}[unit]

    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(set(nums))
    result = []
    i = 0
    while i < len(nums):
        start = nums[i]
        while i + 1 < len(nums) and nums[i] + 1 == nums[i + 1]:
            i += 1
        end = nums[i]
        
        if end - start >= 2:
            result.append(f"{start}..{end}")
        else:
            result.extend(str(num) for num in nums[start:i+1])
        
        i += 1
    
    return ",".join(result)
```
Coding: Top customers Didn't work
```python
from typing import List, Tuple
from decimal import Decimal

def top_customers(rows: List[dict], n: int) -> List[Tuple[str, float]]:
    customer_totals = {}
    
    for row in rows:
        customer_name = row.get("customer", "").strip()
        amount_str = row.get("amount", "").strip()
        
        if not customer_name or not amount_str:
            continue
        
        try:
            amount = Decimal(amount_str.replace('$', '').replace(',', ''))
        except (ValueError, DecimalException):
            continue
        
        customer_name = customer_name.lower()
        
        if customer_name not in customer_totals:
            customer_totals[customer_name] = Decimal(0)
        
        customer_totals[customer_name] += amount
    
    sorted_customers = sorted(
        customer_totals.items(),
        key=lambda x: (-x[1], x[0])
    )
    
    return [(name.capitalize(), float(total)) 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:
    # 1. Transliterate accented characters to ASCII and drop non-ASCII
    ascii_title = unicodedata.normalize('NFKD', title).encode('ascii', 'ignore').decode('ascii')

    # 2. Lowercase
    ascii_title = ascii_title.lower()

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

    # 4. Shorten if necessary without cutting a word
    if len(slug) > max_len:
        # Find the last hyphen within the max_len
        cut_pos = slug.rfind('-', 0, max_len)
        if cut_pos == -1:  # If no hyphen found, cut to max_len
            slug = slug[:max_len]
        else:
            slug = slug[:cut_pos]

    return slug
```
Coding: Token bucket Didn't work
```python
from typing import Callable

class TokenBucket:
    def __init__(self, capacity: float, refill_per_sec: float, clock: Callable[[], float]):
        self._capacity = capacity
        self._refill_per_sec = refill_per_sec
        self._clock = clock
        self._tokens = capacity

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

    def _refill(self) -> None:
        elapsed = self._clock() - self._last_refill_time
        refill_amount = min(self._refill_per_sec * elapsed, self._capacity - self._tokens)
        self._tokens = min(self._capacity, self._tokens + refill_amount)
        self._last_refill_time = self._clock()

    def allow(self, cost: float = 1) -> bool:
        self._refill()
        if self._tokens >= cost:
            self._tokens -= cost
            return True
        return False
```
Decisions: Support checkout down 0% right
{
  "department": "technical",
  "urgency": "3",
  "outage": "true"
}
Decisions: Refund wrong plan 0% right
{
  "department": "billing",
  "refund": "true",
  "tone": "frustrated"
}
Decisions: Moderation doxxing 50% right
{
  "policy": {
    "none": 0.95,
    "harassment": 0.01,
    "hate": 0.01,
    "spam": 0.01,
    "self_harm": 0.01
  },
  "personal_info": {
    "true": 0.95,
    "false": 0.05
  }
}
Decisions: Route calendar 0% right
{
  "tool": "calendar",
  "confirm": "true"
}
Decisions: Doc invoice missing due 50% right
{
  "doc_type": {
    "invoice": 1
  },
  "missing_due_date": {
    "false": 1
  }
}
Decisions: Phishing paypal 0% right
{
  "phishing": "true",
  "risk": "3"
}
Decisions: Pii ssn email 0% right
{
  "data_kind": {
    "none": 0.9,
    "contact": 0.05,
    "financial": 0.03,
    "government_id": 0.02,
    "health": 0.0
  },
  "sensitive": "false"
}
Decisions: Review mixed 0% right
{
  "sentiment": "negative",
  "defect": "true",
  "recommend": "false"
}
Documents: Saas escalator (hard) 0% right
{
  "year1_invoice": 64800,
  "year2_invoice": 74352,
  "year3_invoice": 70200,
  "year3_discount_percent": 5,
  "year1_price_per_seat_month": 45,
  "year2_price_per_seat_month": 47.85,
  "year3_price_per_seat_month": 47.85,
  "addon_months_billed": 9,
  "addon_invoice": 25920,
  "total_contract_value": 179040,
  "contract_end_date": "2029-02-28"
}
Documents: Expense thread 67% right
{
  "employee_id": "EMP-20417",
  "destination_city": "LIS",
  "trip_start": "2025-02-24",
  "trip_end": "2025-02-27",
  "approved_items": [
    {
      "date": "2025-02-24",
      "category": "airfare",
      "amount_usd": 1094.16
    },
    {
      "date": "2025-02-24",
      "category": "ground_transport",
      "amount_usd": 36.0
    },
    {
      "date": "2025-02-25",
      "category": "meals",
      "amount_usd": 65.0
    },
    {
      "date": "2025-02-26",
      "category": "lodging",
      "amount_usd": 392.64
    },
    {
      "date": "2025-02-27",
      "category": "ground_transport",
      "amount_usd": 41.5
    }
  ],
  "rejected_item_count": 1,
  "per_diem_days": 3,
  "per_diem_usd": 65.0,
  "total_reimbursable_usd": 1286.29,
  "approver_email": "priya.raman@corvane.com"
}
Documents: Lease amendment 67% 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": 2227.00,
  "late_fee_from_2025_06_01": 107.50,
  "security_deposit": 2150,
  "total_pet_deposits": 800,
  "total_monthly_payment_july_2025": 2255.00,
  "move_in_payment": 3350
}
Documents: Ticket SLA 73% right
{
  "ticket_id": "48213",
  "account_id": "ACC-7731",
  "open_issue": "inventory_sync",
  "resolved_issues": ["billing_address"],
  "affected_orders": ["SO-99815", "SO-99820", "SO-99827"],
  "priority": "P2",
  "sla_due_local": "2025-09-13T09:00:00",
  "sla_due_utc": "2025-09-13T15:00:00Z",
  "reissued_invoice": "INV-2025-0812"
}
Documents: Sales footnotes 0% right
{
  "q3_total_usd": 4902,
  "q2_total_usd": 4585,
  "q2_central_originally_reported_usd": 3047,
  "q2_to_q3_change_pct": null,
  "top_region_q3": "West",
  "fastest_growing_region_q1_to_q3": "East",
  "regions_declining_q2_to_q3": ["Central"],
  "international_q3_organic_usd": 1951,
  "west_excluding_mountain_q3_usd": 2893
}

Size: 3.2B parameters. First tested OCT 11.

Models that scored about the same

Comments

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