Review · updated OCT 11

Ling 3.0 Flash VL review: a solid all-rounder

It scored 92 out of 100, #6 of 56. It solved 26 of 30 coding jobs and scored 98 on reading documents. Runs on a Mac with 128 GB.

The short version
  • Ling 3.0 Flash VL is a free model from inclusionai that you can run on your own computer. In our tests it's a solid all-rounder: 92 out of 100, #6 of 56.
  • It solved 26 of 30 coding jobs and scored 98 on reading documents. On our hardest tasks it scored 83.
  • Runs on a Mac with 128 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. Ling 3.0 Flash VL got 26 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. Ling 3.0 Flash VL scored 98; the best model scored 100.

TestScorePublic questionsSecret questions
Coding877191
Reading documents989898
Decisions9610095

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

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 Ling 3.0 Flash VL 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 GBNo–
Graphics card, 32 GBNo–
Mac, 16 GBNo–
Mac, 24 GBNo–
Mac, 32 GBNo–
Mac, 48 GBNo–
Mac, 64 GBNo–
Mac, 96 GBNo–
Mac, 128 GBYesQ5

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

How to try it

Search for "Ling 3.0 Flash VL 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 Ling 3.0 Flash VL wrote for our public questions, word for word.

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

def parse_ini(text: str) -> dict[str, dict[str, str]]:
    sections: dict[str, dict[str, str]] = {}
    current_section: str | None = None
    last_was_key = False

    def strip_comment(value: str) -> str:
        in_quotes = False
        i = 0
        while i < len(value):
            c = value[i]
            if c == '"':
                in_quotes = not in_quotes
            elif not in_quotes and c in ';#' and i > 0 and value[i - 1] == ' ':
                return value[:i - 1]
            i += 1
        return value

    section_re = re.compile(r'^\s*\[([^\]]*)\]\s*$')
    ref_re = re.compile(r'\$\{([^}]+)\}')

    for line in text.splitlines():
        stripped = line.strip()

        # Rule 3: empty or comment lines
        if stripped == '' or (len(stripped) > 0 and stripped[0] in ';#'):
            last_was_key = False
            continue

        # Rule 1: section header
        m = section_re.match(line)
        if m:
            section_name = m.group(1).strip()
            current_section = section_name
            if current_section not in sections:
                sections[current_section] = {}
            last_was_key = False
            continue

        # Rule 6: continuation line
        if len(line) > 0 and line[0] in ' \t' and last_was_key:
            if current_section is not None and sections.get(current_section):
                last_key = list(sections[current_section].keys())[-1]
                sections[current_section][last_key] += '\n' + stripped
            last_was_key = True
            continue

        # Rule 2: key = value or key : value
        eq_pos = line.find('=')
        colon_pos = line.find(':')

        if eq_pos != -1 or colon_pos != -1:
            if eq_pos == -1:
                split_pos = colon_pos
            elif colon_pos == -1:
                split_pos = eq_pos
            else:
                split_pos = min(eq_pos, colon_pos)

            key = line[:split_pos].strip().lower()
            raw_value = line[split_pos + 1:]

            # Rule 4: strip inline comments
            raw_value = strip_comment(raw_value)
            raw_value = raw_value.strip()

            # Rule 5: double-quoted value
            if len(raw_value) >= 2 and raw_value.startswith('"') and raw_value.endswith('"'):
                value = raw_value[1:-1]
            else:
                value = raw_value.strip()

            # Rule 10: key before section header
            if current_section is None:
                raise ValueError("Key line before any section header")

            sections[current_section][key] = value
            last_was_key = True
            continue

        # Rule 10: malformed line
        raise ValueError(f"Malformed line: {line!r}")

    # Interpolation phase
    default_data = sections.get('DEFAULT', {})

    def get_raw(section: str, key: str) -> str:
        if section in sections and key in sections[section]:
            return sections[section][key]
        if key in default_data:
            return default_data[key]
        raise KeyError(f"Key '{key}' not found in section '{section}'")

    resolving: set[tuple[str, str]] = set()
    cache: dict[tuple[str, str], str] = {}

    def resolve(section: str, key: str) -> str:
        if (section, key) in cache:
            return cache[(section, key)]
        if (section, key) in resolving:
            raise ValueError(f"Cycle detected: {section}:{key}")

        raw = get_raw(section, key)
        resolving.add((section, key))
        try:
            result = _interpolate(raw, section)
        finally:
            resolving.discard((section, key))
        cache[(section, key)] = result
        return result

    def _interpolate(raw_value: str, current_section: str) -> str:
        def replace_ref(match: re.Match) -> str:
            ref = match.group(1)
            if ':' in ref:
                sec, key = ref.split(':', 1)
                sec = sec.strip()
                key = key.strip()
            else:
                sec = current_section
                key = ref.strip()
            return resolve(sec, key)

        return ref_re.sub(replace_ref, raw_value)

    result: dict[str, dict[str, str]] = {}
    for sec_name, sec_data in sections.items():
        if sec_name == 'DEFAULT':
            continue
        result[sec_name] = {}
        for key in sec_data:
            result[sec_name][key] = resolve(sec_name, key)

    return result
```
Coding: TTL LRU (hard) Worked
```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._data = OrderedDict()  # key -> (value, expiry_time)
        self._hits = 0
        self._misses = 0
        self._evictions = 0
        self._expirations = 0

    def put(self, key, value):
        if self._capacity == 0:
            return
        now = self._clock()
        if key in self._data:
            # Replace: refresh expiry and recency
            self._data[key] = (value, now + self._ttl)
            self._data.move_to_end(key)
            return
        # New key: first remove all expired entries
        self._remove_expired(now)
        if len(self._data) >= self._capacity:
            # Evict LRU entry
            self._data.popitem(last=False)
            self._evictions += 1
        self._data[key] = (value, now + self._ttl)

    def get(self, key, default=None):
        now = self._clock()
        if key not in self._data:
            self._misses += 1
            return default
        value, expiry = self._data[key]
        if now >= expiry:
            # Expired: remove, count as expiration AND miss
            del self._data[key]
            self._expirations += 1
            self._misses += 1
            return default
        # Not expired: mark as most recently used
        self._data.move_to_end(key)
        self._hits += 1
        return value

    def __len__(self):
        now = self._clock()
        return sum(1 for v in self._data.values() if now < v[1])

    def stats(self):
        return {
            "hits": self._hits,
            "misses": self._misses,
            "evictions": self._evictions,
            "expirations": self._expirations,
        }

    def _remove_expired(self, now):
        expired_keys = [k for k in self._data if now >= self._data[k][1]]
        for k in expired_keys:
            del self._data[k]
            self._expirations += 1
```
Coding: Parse duration Didn't work
```python
import re


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

    if re.fullmatch(r'\s*(?:\d+\s*[hms])*\s*', s, re.IGNORECASE) is None:
        raise ValueError("Invalid format")

    parts = list(re.finditer(r'(\d+)\s*([hms])', s, re.IGNORECASE))

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

    order = {'h': 0, 'm': 1, 's': 2}
    last_idx = -1
    seen = set()
    total_seconds = 0

    for part in parts:
        value = int(part.group(1))
        unit = part.group(2).lower()

        if unit in seen:
            raise ValueError(f"Repeated unit: {unit}")
        seen.add(unit)

        idx = order[unit]
        if idx <= last_idx:
            raise ValueError(f"Units out of order")
        last_idx = idx

        if unit == 'h':
            total_seconds += value * 3600
        elif unit == 'm':
            total_seconds += value * 60
        elif unit == 's':
            total_seconds += value

    return total_seconds
```
Coding: Summarize ranges Worked
```python
def summarize_ranges(nums: list[int]) -> str:
    if not nums:
        return ""
    nums = sorted(set(nums))
    result = []
    start = 0
    for i in range(1, len(nums) + 1):
        if i == len(nums) or nums[i] != nums[i - 1] + 1:
            end = i - 1
            if end - start >= 2:
                result.append(f"{nums[start]}..{nums[end]}")
            else:
                for j in range(start, end + 1):
                    result.append(str(nums[j]))
            start = i
    return ",".join(result)
```
Coding: Top customers Worked
```python
def top_customers(rows: list[dict], n: int) -> list[tuple[str, float]]:
    customers = {}

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

        amount_raw = row.get("amount", "")
        if not isinstance(amount_raw, str):
            continue

        try:
            cleaned = amount_raw.strip().replace(",", "").replace("$", "")
            amount = float(cleaned)
        except (ValueError, TypeError):
            continue

        key = name.lower()
        if key not in customers:
            customers[key] = [name, 0.0]
        customers[key][1] += amount

    result = [(name, round(total, 2)) for name, total in customers.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:
    # Step 1: Transliterate accented characters to ASCII via NFD decomposition,
    # then drop any remaining non-ASCII characters.
    normalized = unicodedata.normalize("NFD", title)
    ascii_text = normalized.encode("ascii", "ignore").decode("ascii")

    # Step 2: Lowercase
    ascii_text = ascii_text.lower()

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

    # Step 4: Truncate without cutting a word if possible
    if len(slug) > max_len:
        words = slug.split("-")
        parts: list[str] = []
        current_len = 0
        for i, word in enumerate(words):
            add_len = len(word) if i == 0 else len(word) + 1  # +1 for separator "-"
            if current_len + add_len <= max_len:
                parts.append(word)
                current_len += add_len
            else:
                # If even the first word doesn't fit, hard-cut it
                if i == 0:
                    parts.append(word[:max_len])
                break
        slug = "-".join(parts)

    # Step 5: Ensure result never ends with "-"
    slug = slug.strip("-")

    return slug
```
Coding: Token bucket Worked
```python
class TokenBucket:
    def __init__(self, capacity: float, refill_per_sec: float, clock):
        self._capacity = float(capacity)
        self._refill_per_sec = refill_per_sec
        self._clock = clock
        self._tokens = self._capacity
        self._last_time = clock()

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

    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) 100% right
{"outcome": {"full_refund": 0.0, "partial_refund": 0.0, "store_credit": 1.0, "nothing": 0.0}, "defective": {"true": 0.0, "false": 1.0}}
Decisions: Incident matrix (hard) 100% right
{"severity": {"0": 0.01, "1": 0.95, "2": 0.03, "3": 0.01}, "page": {"true": 0.05, "false": 0.95}}
Decisions: Tool followup (hard) 100% right
{"tool": {"calendar": 1.0}, "confirm": {"true": 1.0}}
Decisions: Legit security alert (hard) 100% right
{"phishing": {"true": 0.0, "false": 1.0}, "action_needed": {"true": 0.0, "false": 1.0}}
Decisions: Meeting slot (hard) 100% right
{"slot": {"A": 0.0, "B": 1.0, "C": 0.0, "D": 0.0}, "raj_last": {"true": 1.0, "false": 0.0}}
Decisions: Review mixed (hard) 100% right
{"hardware": {"true": 0.99, "false": 0.01}, "support": {"true": 0.99, "false": 0.01}}
Decisions: Support checkout down 100% right
{"department": {"technical": 0.9, "billing": 0.05, "account": 0.01, "shipping": 0.01, "sales": 0.03}, "urgency": {"0": 0.0, "1": 0.0, "2": 0.05, "3": 0.95}, "outage": {"true": 0.98, "false": 0.02}}
Decisions: Refund wrong plan 100% right
{"department": {"billing": 1.0, "technical": 0.0, "account": 0.0, "shipping": 0.0, "sales": 0.0}, "refund": {"true": 1.0, "false": 0.0}, "tone": {"frustrated": 0.0, "calm": 1.0}}
Decisions: Moderation doxxing 100% right
{"policy": {"none": 0.0, "harassment": 1.0, "hate": 0.0, "spam": 0.0, "self_harm": 0.0}, "personal_info": {"true": 1.0, "false": 0.0}}
Decisions: Route calendar 100% right
{"confirm": {"true": 0.95, "false": 0.05}, "tool": {"web_search": 0.0, "calculator": 0.0, "calendar": 0.95, "email": 0.0, "none": 0.0}}
Decisions: Doc invoice missing due 100% right
{"doc_type": {"invoice": 1.0, "resume": 0.0, "contract": 0.0, "bank_statement": 0.0, "other": 0.0}, "missing_due_date": {"true": 1.0, "false": 0.0}}
Decisions: Phishing paypal 100% right
{"phishing": {"true": 0.99, "false": 0.01}, "risk": {"0": 0.0, "1": 0.0, "2": 0.0, "3": 0.99}}
Decisions: Pii ssn email 100% right
{"data_kind": {"none": 0.0, "contact": 0.0, "financial": 0.0, "government_id": 1.0, "health": 0.0}, "sensitive": {"true": 1.0, "false": 0.0}}
Decisions: Review mixed 100% right
{
  "sentiment": {"positive": 0.1, "neutral": 0.0, "negative": 0.9},
  "defect": {"true": 0.95, "false": 0.05},
  "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.0,"year2_invoice":61236.0,"addon_months_billed":6,"addon_invoice":38556.0,"year3_invoice":134946.0,"year3_discount_percent":15.0,"total_contract_value":293058.0,"contract_end_date":"2027-02-28"}
Documents: Expense thread 96% right
{"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.6},{"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":195.0,"total_reimbursable_usd":2159.25,"approver_email":"pri***@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.00, "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 92% right
{"ticket_id":"48213","account_id":"ACC-7731","open_issue":"inventory_sync","resolved_issues":["billing_address","invoice_pdf"],"affected_orders":["SO-99812","SO-99820","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: 125B parameters. First tested OCT 11.

Models that scored about the same

Comments

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