llmfit/scripts/validate_generation_scoring.py
Alex Jones 6e024b2e51
feat(models): add the Qwen3.8 family and fix Qwen generation parsing (#893)
Pin all four official Qwen3.8 repos in the curated catalog:

  Qwen/Qwen3.8-27B             27.78B dense, 64 layers (16 full / 48 linear)
  Qwen/Qwen3.8-27B-FP8         same weights, FP8 checkpoint
  Qwen/Qwen3.8-2.4T-A95B       2.446T total / 95B active, 512 experts (10 active),
                               92 layers (23 full / 69 linear)
  Qwen/Qwen3.8-2.4T-A95B-FP8   same, FP8 checkpoint

The automated weekly refresh in #892 picked up three of these via trending
discovery, but none were in TARGET_MODELS, so they could drop out of any
later scrape; the non-FP8 2.4T-A95B was missing entirely. Catalog entries
were merged in surgically (four entries) rather than by a full re-scrape so
this diff stays reviewable alongside #892.

infer_attention_layout_from_name now matches qwen3.8- and handles the new
-a95b 92-layer shape, and OLLAMA_MAPPINGS gains qwen3.8-27b -> qwen3.8:27b
(Ollama publishes only the 27B; the 2.4T has no library entry).

Also fixes a generation-scoring bug this surfaced. parse_generation
consulted the architecture string before the model name, but Qwen3.6 and
Qwen3.8 both ship as model_type "qwen3_5" -- so every Qwen3.6 model in the
catalog scored as generation 3.5, and the name-based 3.6 branch added in
5397a8a was dead code for anything with an architecture set. The existing
test passed None for architecture, so it never caught this.

qwen_minor_generation_from_name is now consulted first inside the Qwen arch
branch. It matches only explicit minor versions and returns None for a bare
"Qwen3", so qwen3_next still resolves from the architecture string instead
of regressing to 3.0. scripts/validate_generation_scoring.py, which
documents itself as a mirror of the Rust logic, is updated to match.

Quality bonus is (generation - 1) * 3 clamped to 9, so Qwen3.6 models gain
+0.3 and Qwen3.8 lands at +8.4.
2026-08-17 07:57:27 +01:00

379 lines
12 KiB
Python

#!/usr/bin/env python3
"""
Validate generation-aware quality scoring on the scraped model database.
Loads hf_models.json and applies the same generation parsing logic as the Rust
code to demonstrate that newer model generations are scored appropriately.
"""
import json
import re
import sys
from collections import defaultdict
def qwen_minor_generation_from_name(name_lower: str) -> float | None:
"""Mirror of models::qwen_minor_generation_from_name in Rust.
Only matches names carrying an explicit minor version; a bare ``Qwen3``
returns ``None`` so callers can fall back to the architecture string.
"""
for dotted, underscored, generation in (
("qwen3.8", "qwen3_8", 3.8),
("qwen3.6", "qwen3_6", 3.6),
("qwen3.5", "qwen3_5", 3.5),
("qwen2.5", "qwen2_5", 2.5),
):
if dotted in name_lower or underscored in name_lower:
return generation
return None
def parse_generation(architecture: str | None, name: str) -> float | None:
"""Mirror of models::parse_generation in Rust."""
if architecture:
arch = architecture.lower()
# DeepSeek
if arch.startswith("deepseek"):
if "v4" in arch:
return 4.0
elif "v3" in arch:
return 3.0
elif "v2" in arch:
return 2.0
return 1.0
# Qwen
if arch.startswith("qwen"):
suffix = arch[len("qwen"):]
# Qwen3.6 and Qwen3.8 ship under the `qwen3_5` architecture string,
# so an explicit minor version in the repo name wins over the arch.
named = qwen_minor_generation_from_name(name.lower())
if named is not None:
return named
if suffix.startswith("3_5") or suffix.startswith("3.5"):
return 3.5
if suffix.startswith("3_next") or suffix.startswith("3next"):
return 3.8
if suffix.startswith("3"):
return 3.0
if suffix.startswith("2"):
return 2.0
if suffix.startswith("1"):
return 1.0
return 1.0
# Llama
if arch.startswith("llama"):
suffix = arch[len("llama"):]
if suffix.startswith("4"):
return 4.0
# fall through to name
# Gemma
if arch.startswith("gemma"):
suffix = arch[len("gemma"):]
if suffix.startswith("4"):
return 4.0
if suffix.startswith("3"):
return 3.0
if suffix.startswith("2"):
return 2.0
return 1.0
# Phi
if arch.startswith("phi"):
suffix = arch[len("phi"):]
if suffix.startswith("4"):
return 4.0
if suffix.startswith("3") or suffix.startswith("moe"):
return 3.0
if suffix.startswith("2"):
return 2.0
return 1.0
# Mistral/Mixtral
if arch.startswith("mistral") or arch.startswith("mixtral"):
return 1.0
# Cohere
if arch.startswith("cohere"):
suffix = arch[len("cohere"):]
if suffix.startswith("2"):
return 2.0
return 1.0
# Falcon
if arch.startswith("falcon"):
suffix = arch[len("falcon"):]
if suffix.startswith("3"):
return 3.0
return 1.0
# Granite
if arch.startswith("granite"):
suffix = arch[len("granite"):]
if suffix.startswith("4"):
return 4.0
return 1.0
# Fallback: name-based
name_lower = name.lower()
named = qwen_minor_generation_from_name(name_lower)
if named is not None:
return named
if "qwen3" in name_lower:
return 3.0
if "qwen2" in name_lower:
return 2.0
if "llama-4" in name_lower or "llama4" in name_lower:
return 4.0
if "llama-3.3" in name_lower or "llama3.3" in name_lower:
return 3.3
if "llama-3.2" in name_lower or "llama3.2" in name_lower:
return 3.2
if "llama-3.1" in name_lower or "llama3.1" in name_lower:
return 3.1
if "llama-3" in name_lower or "llama3" in name_lower:
return 3.0
if "llama-2" in name_lower or "llama2" in name_lower:
return 2.0
if "gemma-4" in name_lower or "gemma4" in name_lower:
return 4.0
if "gemma-3" in name_lower or "gemma3" in name_lower:
return 3.0
if "gemma-2" in name_lower or "gemma2" in name_lower:
return 2.0
if "deepseek-v4" in name_lower:
return 4.0
if "deepseek-v3" in name_lower:
return 3.0
if "deepseek-v2" in name_lower:
return 2.0
if "phi-4" in name_lower or "phi4" in name_lower:
return 4.0
if "phi-3" in name_lower or "phi3" in name_lower:
return 3.0
return None
def generation_bonus(architecture: str | None, name: str) -> float:
gen = parse_generation(architecture, name)
if gen is None:
return 0.0
return min((gen - 1.0) * 3.0, 9.0)
def params_b(model: dict) -> float:
"""Extract parameter count in billions."""
if model.get("parameters_raw"):
return model["parameters_raw"] / 1e9
pc = model.get("parameter_count", "")
m = re.search(r"([\d.]+)\s*[Bb]", pc)
if m:
return float(m.group(1))
m = re.search(r"([\d.]+)\s*[Mm]", pc)
if m:
return float(m.group(1)) / 1000
return 0.0
def quality_score_old(model: dict) -> float:
"""Old scoring without generation bonus."""
params = params_b(model)
if params < 1.0:
base = 30.0
elif params < 3.0:
base = 45.0
elif params < 7.0:
base = 60.0
elif params < 10.0:
base = 75.0
elif params < 20.0:
base = 82.0
elif params < 40.0:
base = 89.0
else:
base = 95.0
name_lower = model["name"].lower()
if "qwen" in name_lower:
family_bump = 2.0
elif "deepseek" in name_lower:
family_bump = 3.0
elif "llama" in name_lower:
family_bump = 2.0
elif "mistral" in name_lower or "mixtral" in name_lower:
family_bump = 1.0
elif "gemma" in name_lower:
family_bump = 1.0
else:
family_bump = 0.0
return min(base + family_bump - 5.0, 100.0) # -5 for Q4_K_M penalty
def quality_score_new(model: dict) -> float:
"""New scoring with generation bonus."""
params = params_b(model)
if params < 1.0:
base = 30.0
elif params < 3.0:
base = 45.0
elif params < 7.0:
base = 60.0
elif params < 10.0:
base = 75.0
elif params < 20.0:
base = 82.0
elif params < 40.0:
base = 89.0
else:
base = 95.0
name_lower = model["name"].lower()
if "qwen" in name_lower:
family_bump = 2.0
elif "deepseek" in name_lower:
family_bump = 3.0
elif "llama" in name_lower:
family_bump = 2.0
elif "mistral" in name_lower or "mixtral" in name_lower:
family_bump = 1.0
elif "gemma" in name_lower:
family_bump = 1.0
else:
family_bump = 0.0
gen_bonus = generation_bonus(model.get("architecture"), model["name"])
return min(base + family_bump + gen_bonus - 5.0, 100.0) # -5 for Q4_K_M penalty
def main():
data_path = "llmfit-core/data/hf_models.json"
with open(data_path) as f:
models = json.load(f)
total = len(models)
print(f"Loaded {total} models from {data_path}\n")
# Count models with parseable generation
gen_counts = defaultdict(int)
family_gens = defaultdict(set)
no_gen = 0
has_gen = 0
for m in models:
gen = parse_generation(m.get("architecture"), m["name"])
if gen is not None:
has_gen += 1
gen_counts[gen] += 1
# Extract family
name_lower = m["name"].lower()
for fam in ["qwen", "llama", "deepseek", "gemma", "phi", "mistral", "falcon", "granite"]:
if fam in name_lower:
family_gens[fam].add(gen)
break
else:
no_gen += 1
print(f"Generation coverage: {has_gen}/{total} ({100*has_gen/total:.1f}%)")
print(f"No generation info: {no_gen}/{total} ({100*no_gen/total:.1f}%)\n")
print("Generation distribution:")
for gen in sorted(gen_counts.keys()):
print(f" Gen {gen:>4.1f}: {gen_counts[gen]:>4} models")
print(f"\nFamilies with multiple generations ({len([f for f in family_gens if len(family_gens[f]) > 1])}):")
for fam in sorted(family_gens.keys()):
gens = sorted(family_gens[fam])
if len(gens) > 1:
print(f" {fam:>10}: gens {', '.join(f'{g:.1f}' for g in gens)}")
# Show specific ranking improvements
print("\n" + "=" * 70)
print("KEY COMPARISONS: Generation-aware scoring fixes")
print("=" * 70)
comparisons = [
("Qwen/Qwen3.6-35B-A3B", "Qwen/Qwen2.5-72B-Instruct"),
("Qwen/Qwen3-8B", "Qwen/Qwen2.5-7B-Instruct"),
("google/gemma-4-E2B-it", "google/gemma-2-2b-it"),
("google/gemma-3-27b-it", "google/gemma-2-27b-it"),
]
model_lookup = {m["name"]: m for m in models}
for newer_name, older_name in comparisons:
newer = model_lookup.get(newer_name)
older = model_lookup.get(older_name)
if not newer or not older:
print(f"\n SKIP: {newer_name} or {older_name} not found in database")
continue
old_newer = quality_score_old(newer)
old_older = quality_score_old(older)
new_newer = quality_score_new(newer)
new_older = quality_score_new(older)
newer_gen = parse_generation(newer.get("architecture"), newer["name"])
older_gen = parse_generation(older.get("architecture"), older["name"])
print(f"\n {newer_name} (gen {newer_gen}, {params_b(newer):.1f}B)")
print(f" vs {older_name} (gen {older_gen}, {params_b(older):.1f}B)")
print(f" OLD: {old_newer:.1f} vs {old_older:.1f} (gap: {old_older - old_newer:+.1f})")
print(f" NEW: {new_newer:.1f} vs {new_older:.1f} (gap: {new_older - new_newer:+.1f})")
if old_older - old_newer > new_older - new_newer:
print(f" ✓ Gap narrowed by {(old_older - old_newer) - (new_older - new_newer):.1f} points")
if new_newer > new_older:
print(f" ✓ RANKING FIXED: newer model now scores higher")
# Show top models by new score in each family
print("\n" + "=" * 70)
print("TOP 5 BY QUALITY SCORE (new vs old) — selected families")
print("=" * 70)
for family in ["qwen", "llama", "gemma", "deepseek"]:
family_models = [m for m in models if family in m["name"].lower()]
# Sort by new score, take top 5
family_models.sort(key=lambda m: quality_score_new(m), reverse=True)
print(f"\n {family.upper()} (top 5):")
for m in family_models[:5]:
gen = parse_generation(m.get("architecture"), m["name"])
old = quality_score_old(m)
new = quality_score_new(m)
print(f" {m['name'][:50]:<50} gen={gen} old={old:5.1f} new={new:5.1f} Δ={new-old:+.1f}")
# Summary stats
print("\n" + "=" * 70)
print("SUMMARY")
print("=" * 70)
score_changes = []
for m in models:
old = quality_score_old(m)
new = quality_score_new(m)
if new != old:
score_changes.append((m["name"], old, new, new - old))
print(f"\n Models with score changes: {len(score_changes)}/{total} ({100*len(score_changes)/total:.1f}%)")
print(f" Models unchanged: {total - len(score_changes)}/{total}")
if score_changes:
deltas = [x[3] for x in score_changes]
print(f" Average score increase: {sum(deltas)/len(deltas):+.2f}")
print(f" Max score increase: {max(deltas):+.1f}")
print(f" Min score increase: {min(deltas):+.1f}")
if __name__ == "__main__":
main()