- 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.
| Test | Score | Public questions | Secret questions |
|---|---|---|---|
| Coding | 70 | 86 | 65 |
| Reading documents | 59 | 76 | 54 |
| Decisions | 83 | 86 | 82 |
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 computer | Runs it? | Version to download |
|---|---|---|
| Graphics card, 8 GB | No | – |
| Graphics card, 12 GB | No | – |
| Graphics card, 16 GB | No | – |
| Graphics card, 24 GB | No | – |
| Graphics card, 32 GB | No | – |
| Mac, 16 GB | No | – |
| Mac, 24 GB | No | – |
| Mac, 32 GB | No | – |
| Mac, 48 GB | No | – |
| Mac, 64 GB | No | – |
| Mac, 96 GB | No | – |
| Mac, 128 GB | Yes | Q5 |
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.
Comments
Sign in with GitHub to comment. Spam and abuse are hidden automatically.