Review · updated OCT 11

Laguna S 2.1 review: decent, with trade-offs

It scored 64 out of 100, #25 of 56. It solved 21 of 30 coding jobs and scored 59 on reading documents. Runs on a Mac with 128 GB.

The short version
  • Laguna S 2.1 is a free model from poolside that you can run on your own computer. In our tests it's decent, with trade-offs: 64 out of 100, #25 of 56.
  • It solved 21 of 30 coding jobs and scored 59 on reading documents. On our hardest tasks it scored 46.
  • 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. Laguna S 2.1 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. Laguna S 2.1 scored 59; the best model scored 100.

TestScorePublic questionsSecret questions
Coding708665
Reading documents597654
Decisions838682

On the 18 hardest tasks (included in the scores above) it scored 46. 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 Laguna S 2.1 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 "Laguna S 2.1 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 Laguna S 2.1 wrote for our public questions, word for word.

Coding: INI parser (hard) Worked
```python
import re
from typing import Dict, List, Tuple, Optional

def parse_ini(text: str) -> Dict[str, Dict[str, str]]:
    lines = text.splitlines()
    current_section: Optional[str] = None
    sections: Dict[str, Dict[str, str]] = {}
    defaults: Dict[str, str] = {}

    i = 0
    while i < len(lines):
        line = lines[i]
        stripped = line.strip()

        # Skip empty lines and comments
        if not stripped or stripped[0] in ';#':
            i += 1
            continue

        # Section header
        m = re.match(r'^\s*\[([^\]]*)\]\s*$', line)
        if m:
            section_name = m.group(1).strip()
            current_section = section_name
            if section_name == 'DEFAULT':
                # Use defaults dict
                sections.setdefault('DEFAULT', {})
            else:
                sections.setdefault(section_name, {})
            i += 1
            continue

        # Key-value line
        # Check if it's a continuation line (starts with whitespace and follows a key line)
        if line[0] in ' \t' and current_section is not None:
            # Continuation line - handled after parsing key lines
            i += 1
            continue

        if current_section is None:
            raise ValueError("Key-value line before any section header")

        # Parse key-value
        # Find first = or :
        eq_pos = line.find('=')
        colon_pos = line.find(':')

        if eq_pos == -1 and colon_pos == -1:
            raise ValueError(f"Malformed line: {line}")

        # Determine split position
        split_pos = -1
        if eq_pos != -1 and colon_pos != -1:
            split_pos = min(eq_pos, colon_pos)
        elif eq_pos != -1:
            split_pos = eq_pos
        else:
            split_pos = colon_pos

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

        # Handle inline comments and quoted values
        value = parse_value_with_comments(value_part)

        # Handle continuation lines
        full_value = value
        j = i + 1
        while j < len(lines):
            next_line = lines[j]
            next_stripped = next_line.strip()
            # Check if it's a continuation line (starts with whitespace and isn't empty/comment)
            if next_stripped and next_line[0] in ' \t' and next_stripped[0] not in ';#':
                # Check if it looks like a key-value line
                next_eq = next_line.find('=')
                next_colon = next_line.find(':')
                if next_eq != -1 or next_colon != -1:
                    # It's a new key-value line, not continuation
                    break
                full_value += "\n" + next_stripped
                j += 1
            else:
                break

        if current_section == 'DEFAULT':
            defaults[key] = full_value
        else:
            sections[current_section][key] = full_value

        i = j

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

    # Merge DEFAULT values into all other sections without overriding
    for section_name in sections:
        # Start with defaults
        merged = {}
        for k, v in defaults.items():
            merged[k] = v
        # Apply section's own values
        for k, v in sections[section_name].items():
            merged[k] = v
        sections[section_name] = merged

    # Apply interpolation
    # Build a function to resolve references
    def resolve_value(section_name: str, key: str, value: str, _visited: set) -> str:
        # Find all ${...} patterns
        pattern = re.compile(r'\$\{([^}]+)\}')
        
        def replace_ref(match):
            ref = match.group(1)
            if ':' in ref:
                sec_name, ref_key = ref.split(':', 1)
                if sec_name not in sections and sec_name not in ('DEFAULT',):
                    raise KeyError(f"Section '{sec_name}' not found")
                sec_dict = sections.get(sec_name, {}) if sec_name != 'DEFAULT' else defaults
                if ref_key.lower() not in sec_dict:
                    raise KeyError(f"Key '{ref_key}' not found in section '{sec_name}'")
                return resolve_value(sec_name, ref_key.lower(), sec_dict[ref_key.lower()], _visited | {(sec_name, ref_key.lower())})
            else:
                ref_key = ref.lower()
                if ref_key not in sections[section_name]:
                    raise KeyError(f"Key '{ref}' not found in section '{section_name}'")
                if (section_name, ref_key) in _visited:
                    raise ValueError(f"Circular reference detected: {ref}")
                return resolve_value(section_name, ref_key, sections[section_name][ref_key], _visited | {(section_name, ref_key)})

        return pattern.sub(replace_ref, value)

    # Resolve all interpolations
    for section_name in sections:
        resolved_section = {}
        for key, value in sections[section_name].items():
            resolved_section[key] = resolve_value(section_name, key, value, {(section_name, key)})
        sections[section_name] = resolved_section

    return sections


def parse_value_with_comments(value: str) -> str:
    # Handle quoted values
    stripped = value.strip()
    if stripped.startswith('"') and stripped.endswith('"') and len(stripped) >= 2:
        return stripped[1:-1]
    
    # Remove inline comments
    # Find ; or # preceded by whitespace
    i = 0
    while i < len(value):
        if value[i] in ';#' and i > 0 and value[i-1] in ' \t':
            return value[:i].strip()
        i += 1
    
    return value.strip()
```
Coding: TTL LRU (hard) Didn't work
```python
import time
from collections import OrderedDict
from typing import Any, Callable, Dict, Optional

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, Any] = OrderedDict()
        self.expiry: Dict[Any, float] = {}
        self.hits = 0
        self.misses = 0
        self.evictions = 0
        self.expirations = 0

    def now(self) -> float:
        return self.clock()

    def put(self, key: Any, value: Any) -> None:
        if self.capacity == 0:
            return

        # If key already exists, remove it to reset recency
        if key in self.cache:
            del self.cache[key]
            del self.expiry[key]

        # If adding a new key would exceed capacity
        if len(self.cache) >= self.capacity:
            # Remove all expired entries first
            self._remove_expired()

            # If still full, evict LRU
            if len(self.cache) >= self.capacity:
                self.cache.popitem(last=False)
                oldest_key = self.expiry.pop(next(iter(self.expiry)))
                self.evictions += 1

        self.cache[key] = value
        self.cache.move_to_end(key)
        self.expiry[key] = self.now() + self.ttl

    def get(self, key: Any, default: Optional[Any] = None) -> Any:
        if self.capacity == 0:
            self.misses += 1
            return default

        if key not in self.cache:
            self.misses += 1
            return default

        # Check expiration
        expire_time = self.expiry.get(key)
        if expire_time is not None and self.now() >= expire_time:
            # Expired, remove and count as expiration
            del self.cache[key]
            del self.expiry[key]
            self.expirations += 1
            self.misses += 1
            return default

        # Valid, mark as recently used
        self.cache.move_to_end(key)
        self.hits += 1
        return self.cache[key]

    def __len__(self) -> int:
        # Count non-expired entries
        current_time = self.now()
        count = 0
        for key in self.cache:
            if self.expiry.get(key) is not None and self.expiry[key] > current_time:
                count += 1
        return count

    def stats(self) -> Dict[str, int]:
        return {
            "hits": self.hits,
            "misses": self.misses,
            "evictions": self.evictions,
            "expirations": self.expirations
        }

    def _remove_expired(self) -> None:
        current_time = self.now()
        expired_keys = [k for k, exp in self.expiry.items() if exp <= current_time]
        for k in expired_keys:
            del self.cache[k]
            del self.expiry[k]
            self.expirations += 1
```
Coding: Parse duration Worked
```python
import re

def parse_duration(s: str) -> int:
    if not s or not s.strip():
        raise ValueError("Invalid input")

    structure_pattern = re.compile(r'^\s*((?:\d+[hmsHMS]\s*)+)\s*$', re.IGNORECASE)
    if not structure_pattern.match(s):
        raise ValueError("Invalid format")

    parts = re.findall(r'\d+[hmsHMS]', s, re.IGNORECASE)
    units = []
    nums = []

    for part in parts:
        unit = part[-1].lower()
        num_str = part[:-1]
        num = int(num_str)
        units.append(unit)
        nums.append(num)

    if len(units) != len(set(units)):
        raise ValueError("Duplicate units")

    order_ranks = {'h': 0, 'm': 1, 's': 2}
    prev_rank = -1
    for unit in units:
        curr_rank = order_ranks[unit]
        if curr_rank < prev_rank:
            raise ValueError("Units out of order")
        prev_rank = curr_rank

    total = 0
    for num, unit in zip(nums, units):
        if unit == 'h':
            total += num * 3600
        elif unit == 'm':
            total += num * 60
        elif unit == 's':
            total += num

    return total
```

The provided function first checks for empty or blank input. It then validates the overall structure using a regex that ensures the input is correctly formatted. It extracts each time component and verifies that units are in the correct order without duplicates. Finally, it calculates and returns the total duration in seconds.
Coding: Summarize ranges Worked
```python
def summarize_ranges(nums: list[int]) -> str:
    if not nums:
        return ""
    
    # Sort and remove duplicates
    nums = sorted(set(nums))
    
    ranges = []
    start = nums[0]
    end = nums[0]
    
    for i in range(1, len(nums)):
        if nums[i] == end + 1:
            end = nums[i]
        else:
            # Finalize the current run
            if start == end:
                ranges.append(str(start))
            else:
                count = end - start + 1
                if count >= 3:
                    ranges.append(f"{start}..{end}")
                else:
                    # List individually
                    ranges.extend(str(x) for x in range(start, end + 1))
            start = nums[i]
            end = nums[i]
    
    # Handle the last run
    if start == end:
        ranges.append(str(start))
    else:
        count = end - start + 1
        if count >= 3:
            ranges.append(f"{start}..{end}")
        else:
            ranges.extend(str(x) for x in range(start, end + 1))
    
    return ",".join(ranges)
```
Coding: Top customers Worked
```python
import re
from collections import defaultdict
from typing import List, Dict, Tuple

def parse_amount(amount_str: str) -> float | None:
    """Parse an amount string like '$1,234.50', '1234.5', '-$5.00' into a float."""
    if not isinstance(amount_str, str):
        return None
    # Remove whitespace
    s = amount_str.strip()
    if not s:
        return None
    # Handle negative sign before or after $
    negative = False
    if s.startswith('-'):
        negative = True
        s = s[1:]
    elif s.startswith('$-'):
        negative = True
        s = s[1:]  # remove $
        # now s starts with '-'
        if s.startswith('-'):
            s = s[1:]
    # Remove dollar sign and commas
    s = s.replace('$', '').replace(',', '').strip()
    if not s:
        return None
    # Validate that it's a number
    try:
        value = float(s)
    except ValueError:
        return None
    if negative:
        value = -value
    return value

def top_customers(rows: List[Dict], n: int) -> List[Tuple[str, float]]:
    customer_totals: Dict[str, float] = defaultdict(float)
    customer_names: Dict[str, str] = {}  # normalized -> first valid name
    
    for row in rows:
        if not isinstance(row, dict):
            continue
        # Get customer name
        customer = row.get("customer")
        if customer is None:
            continue
        customer = str(customer).strip()
        if not customer:
            continue
        normalized = customer.lower()
        
        # Get amount
        amount_str = row.get("amount")
        amount = parse_amount(amount_str)
        if amount is None:
            continue
        
        # Record first valid spelling
        if normalized not in customer_names:
            customer_names[normalized] = customer
        
        # Add to total
        customer_totals[normalized] += amount
    
    # Round totals to 2 decimals
    rounded_totals = {k: round(v, 2) for k, v in customer_totals.items()}
    
    # Sort by total descending, then name ascending
    sorted_customers = sorted(
        rounded_totals.items(),
        key=lambda x: (-x[1], x[0])
    )
    
    # Take top n and format output with original name
    result = []
    for normalized_name, total in sorted_customers[:n]:
        original_name = customer_names[normalized_name]
        result.append((original_name, total))
    
    return result
```
Coding: Slugify Worked
```python
import unicodedata
import re

def slugify(title: str, max_len: int = 50) -> str:
    # 1. Transliterate accented characters to ASCII
    normalized = unicodedata.normalize('NFKD', title)
    ascii_text = ''.join(c for c in normalized if unicodedata.combining(c) == 0)
    
    # Drop non-ASCII characters
    ascii_text = ''.join(c for c in ascii_text if ord(c) < 128)
    
    # 2. Lowercase
    ascii_text = ascii_text.lower()
    
    # 3. Replace every run of characters other than a-z and 0-9 with a single "-",
    #    and strip leading/trailing "-"
    slug = re.sub(r'[^a-z0-9]+', '-', ascii_text)
    slug = slug.strip('-')
    
    # 4. If slug is longer than max_len, shorten without cutting a word
    if len(slug) > max_len:
        words = slug.split('-')
        result = []
        for word in words:
            # Try adding the word
            new_result = result + [word]
            joined = '-'.join(new_result)
            if len(joined) <= max_len:
                result.append(word)
            else:
                break
        slug = '-'.join(result)
        
        # If slug is still empty or first word is too long, hard-cut
        if len(slug) > max_len or (not result and words):
            # Even the first word is too long, hard cut
            if not result and words:
                slug = words[0][:max_len]
            else:
                # Check if hard cutting is needed
                if len(slug) > max_len:
                    slug = slug[:max_len]
    
    # 5. Ensure result never ends with "-"
    slug = slug.rstrip('-')
    
    return slug
```
Coding: Token bucket Worked
```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
        self._last_refill = clock()

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

    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
{"outcome": {"full_refund": 0.0, "partial_refund": 1.0, "store_credit": 0.0, "nothing": 0.0}, "defective": {"true": 0.0, "false": 1.0}}
Decisions: Incident matrix (hard) 0% right
{
  "severity": {"0": 0.0, "1": 0.1, "2": 0.8, "3": 0.1},
  "page": {"true": 0.8, "false": 0.2}
}
Decisions: Tool followup (hard) 100% right
{"tool": {"web_search": 0.0, "calculator": 0.0, "calendar": 0.9, "email": 0.1, "none": 0.0}, "confirm": {"true": 0.7, "false": 0.3}}
Decisions: Legit security alert (hard) 100% right
{
  "phishing": {
    "true": 0.08,
    "false": 0.92
  },
  "action_needed": {
    "true": 0.15,
    "false": 0.85
  }
}
Decisions: Meeting slot (hard) 50% right
{"slot": {"A": 0.0, "B": 0.0, "C": 1.0, "D": 0.0}, "raj_last": {"true": 1.0, "false": 0.0}}
Decisions: Review mixed (hard) 100% right
{"hardware": {"true": 0.95, "false": 0.05}, "support": {"true": 0.9, "false": 0.1}}
Decisions: Support checkout down 100% right
{"department": {"billing": 0.1, "technical": 0.9, "account": 0.0, "shipping": 0.0, "sales": 0.0}, "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.01, "account": 0.02, "shipping": 0.01, "sales": 0.01}, "refund": {"true": 0.98, "false": 0.02}, "tone": {"frustrated": 0.1, "calm": 0.9}}
Decisions: Moderation doxxing 100% right
{
  "policy": {
    "none": 0.0,
    "harassment": 0.95,
    "hate": 0.0,
    "spam": 0.0,
    "self_harm": 0.0
  },
  "personal_info": {
    "true": 0.95,
    "false": 0.05
  }
}
Decisions: Route calendar 100% right
{
  "tool": {
    "web_search": 0.0,
    "calculator": 0.0,
    "calendar": 1.0,
    "email": 0.0,
    "none": 0.0
  },
  "confirm": {
    "true": 1.0,
    "false": 0.0
  }
}
Decisions: Doc invoice missing due 100% right
{
  "doc_type": {
    "invoice": 0.97,
    "resume": 0.01,
    "contract": 0.01,
    "bank_statement": 0.005,
    "other": 0.005
  },
  "missing_due_date": {
    "true": 0.95,
    "false": 0.05
  }
}
Decisions: Phishing paypal 100% right
{
  "phishing": { "true": 0.99, "false": 0.01 },
  "risk": { "0": 0.0, "1": 0.0, "2": 0.02, "3": 0.98 }
}
Decisions: Pii ssn email 100% right
{"data_kind": {"none": 0.02, "contact": 0.03, "financial": 0.05, "government_id": 0.88, "health": 0.02}, "sensitive": {"true": 0.95, "false": 0.05}}
Decisions: Review mixed 100% right
{
  "sentiment": {"positive": 0.15, "neutral": 0.2, "negative": 0.65},
  "defect": {"true": 0.95, "false": 0.05},
  "recommend": {"true": 0.1, "false": 0.9}
}
Documents: Saas escalator (hard) 30% right
{
  "year2_price_per_seat_month": 47.25,
  "year3_price_per_seat_month": 49.60,
  "year1_invoice": 5870.40,
  "year2_invoice": 6049.20,
  "addon_months_billed": 5,
  "addon_invoice": 7810.00,
  "year3_invoice": 6662.40,
  "year3_discount_percent": 15,
  "total_contract_value": 26192.00,
  "contract_end_date": "2027-02-28"
}
Documents: Expense thread 92% 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.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": 3,
  "per_diem_days": 3,
  "per_diem_usd": 195.00,
  "total_reimbursable_usd": 1974.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.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 82% right
{
  "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-15T09:00",
  "sla_due_utc": "2025-09-15T14:00:00Z",
  "reissued_invoice": "INV-2025-0812"
}
Documents: Sales footnotes 78% 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": "West",
  "fastest_growing_region_q1_to_q3": "Central",
  "regions_declining_q2_to_q3": ["East"],
  "international_q3_organic_usd": 1731000,
  "west_excluding_mountain_q3_usd": 4201000
}

Size: 118B parameters. First tested OCT 11.

Models that scored about the same

Comments

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