Skip to content

Commit de01591

Browse files
committed
feat(local): grammar-constrained structured output + thread-safe local batch
1 parent 0d37cf9 commit de01591

12 files changed

Lines changed: 518 additions & 44 deletions

effgen/cli/_main.py

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2617,6 +2617,8 @@ def create_parser():
26172617
eval_parser.add_argument('-o', '--output', help='Output file for results (JSON)')
26182618
eval_parser.add_argument('--difficulty', choices=['easy', 'medium', 'hard'],
26192619
help='Filter test cases by difficulty')
2620+
eval_parser.add_argument('--max-cases', type=int, default=None,
2621+
help='Only run the first N cases (quick subsample)')
26202622
eval_parser.add_argument('--no-animation', action='store_true', default=argparse.SUPPRESS,
26212623
help='Disable the live progress bar (plain output)')
26222624

@@ -2628,11 +2630,18 @@ def create_parser():
26282630
'for a bare id (e.g. '
26292631
'groq:llama-3.1-8b-instant,gpt-5-nano).')
26302632
compare_parser.add_argument('--suite', required=True,
2631-
help='Test suite name')
2633+
help='Built-in suite name (math, tool_use, '
2634+
'reasoning, safety, conversation) OR a path '
2635+
'to your own .jsonl/.json test cases')
26322636
compare_parser.add_argument('--scoring', choices=['exact_match', 'contains', 'regex', 'semantic_similarity', 'llm_judge'],
26332637
default='contains', help='Scoring mode (default: contains)')
26342638
compare_parser.add_argument('--threshold', type=float, default=0.5,
26352639
help='Pass threshold (default: 0.5)')
2640+
compare_parser.add_argument('--max-cases', type=int, default=None,
2641+
help='Only run the first N cases (quick bake-off '
2642+
'on a big suite)')
2643+
compare_parser.add_argument('--difficulty', choices=['easy', 'medium', 'hard'],
2644+
help='Filter test cases by difficulty')
26362645
compare_parser.add_argument('-o', '--output', help='Output file for results (JSON or Markdown)')
26372646
compare_parser.add_argument('--preset', choices=_preset_choices,
26382647
help='Use a preset agent configuration')
@@ -3419,9 +3428,50 @@ def _handle_batch_command(args, cli) -> int:
34193428
logging.getLogger(__name__).debug("Batch agent close() failed", exc_info=True)
34203429

34213430

3431+
def _resolve_eval_suite(suite_arg: str, difficulty=None, max_cases=None):
3432+
"""Resolve a ``--suite`` argument to a ``TestSuite``.
3433+
3434+
Accepts a built-in suite name **or** a path to a ``.jsonl`` / ``.json`` file
3435+
of your own test cases (each an object with ``query``/``expected_output`` and
3436+
optional ``difficulty``/``tags``), so a bake-off can run on your own data —
3437+
not just the bundled suites. Optionally filters by ``difficulty`` and trims to
3438+
the first ``max_cases``. Raises ``KeyError`` (with the list of valid names)
3439+
for an unknown name, or ``FileNotFoundError`` / ``ValueError`` for a bad file.
3440+
"""
3441+
from effgen.eval import get_suite
3442+
from effgen.eval.suites import TestSuite
3443+
3444+
p = Path(suite_arg)
3445+
if p.suffix.lower() in (".jsonl", ".json") or p.exists():
3446+
if not p.exists():
3447+
raise FileNotFoundError(f"Test-case file not found: {suite_arg}")
3448+
from effgen.eval.evaluator import TestCase
3449+
raw = p.read_text(encoding="utf-8")
3450+
records = []
3451+
if p.suffix.lower() == ".json":
3452+
data = json.loads(raw)
3453+
records = data if isinstance(data, list) else [data]
3454+
else:
3455+
records = [json.loads(line) for line in raw.splitlines() if line.strip()]
3456+
cases = [TestCase.from_dict(r) for r in records]
3457+
if not cases:
3458+
raise ValueError(f"No test cases found in {suite_arg}")
3459+
suite = TestSuite(test_cases=cases)
3460+
suite.name = p.stem
3461+
else:
3462+
suite = get_suite(suite_arg)
3463+
3464+
if difficulty:
3465+
from effgen.eval.evaluator import Difficulty
3466+
suite.test_cases = suite.filter(difficulty=Difficulty(difficulty))
3467+
if max_cases is not None and max_cases > 0:
3468+
suite.test_cases = suite.test_cases[:max_cases]
3469+
return suite
3470+
3471+
34223472
def _handle_eval_command(args, cli) -> int:
34233473
"""Handle 'effgen eval' subcommand."""
3424-
from effgen.eval import AgentEvaluator, RegressionTracker, get_suite, list_suites
3474+
from effgen.eval import AgentEvaluator, RegressionTracker, list_suites
34253475
from effgen.eval.evaluator import ScoringMode
34263476

34273477
suite_name = args.suite
@@ -3430,6 +3480,7 @@ def _handle_eval_command(args, cli) -> int:
34303480
scoring = ScoringMode(args.scoring)
34313481
threshold = args.threshold
34323482
difficulty = getattr(args, 'difficulty', None)
3483+
max_cases = getattr(args, 'max_cases', None)
34333484

34343485
try:
34353486
# List suites if requested
@@ -3439,13 +3490,13 @@ def _handle_eval_command(args, cli) -> int:
34393490
cli.print(f" {name:16s}{desc}")
34403491
return 0
34413492

3442-
suite = get_suite(suite_name)
3493+
suite = _resolve_eval_suite(suite_name, difficulty=difficulty, max_cases=max_cases)
34433494

3444-
# Filter by difficulty if specified
3495+
# Report any narrowing applied
34453496
if difficulty:
3446-
from effgen.eval.evaluator import Difficulty
3447-
suite.test_cases = suite.filter(difficulty=Difficulty(difficulty))
34483497
cli.print(f"Filtered to {len(suite.test_cases)} {difficulty} test cases")
3498+
if max_cases:
3499+
cli.print(f"Limited to first {len(suite.test_cases)} cases")
34493500

34503501
cli.print(f"Loading model {model_name}...")
34513502

@@ -3533,22 +3584,29 @@ def _handle_eval_command(args, cli) -> int:
35333584

35343585
def _handle_compare_command(args, cli) -> int:
35353586
"""Handle 'effgen compare' subcommand."""
3536-
from effgen.eval import ModelComparison, get_suite
3587+
from effgen.eval import ModelComparison
35373588
from effgen.eval.evaluator import ScoringMode
35383589

35393590
model_names = [m.strip() for m in args.models.split(',')]
35403591
suite_name = args.suite
35413592
scoring = ScoringMode(args.scoring)
35423593
threshold = args.threshold
35433594
preset_name = getattr(args, 'preset', None)
3595+
difficulty = getattr(args, 'difficulty', None)
3596+
max_cases = getattr(args, 'max_cases', None)
35443597

35453598
# Unknown suite is a user error, not a crash — report cleanly (no traceback)
3546-
# and exit 2 with the list of valid suites.
3599+
# and exit 2 with the list of valid suites. A bad data file is reported the
3600+
# same way.
35473601
try:
3548-
suite = get_suite(suite_name)
3549-
except (KeyError, ValueError):
3602+
suite = _resolve_eval_suite(suite_name, difficulty=difficulty, max_cases=max_cases)
3603+
except (KeyError, ValueError, FileNotFoundError) as exc:
35503604
from effgen.eval import list_suites
3551-
cli.print(f"Unknown suite '{suite_name}'. Available: {', '.join(list_suites())}.")
3605+
cli.print(
3606+
f"Could not load suite '{suite_name}' ({exc}). "
3607+
f"Use a built-in suite ({', '.join(list_suites())}) "
3608+
"or a path to a .jsonl/.json file of test cases."
3609+
)
35523610
return 2
35533611

35543612
agents: dict = {}

effgen/core/agent.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from __future__ import annotations
1414

1515
import asyncio
16+
import functools
1617
import logging
1718
import time
1819
from collections.abc import Callable, Iterator
@@ -208,6 +209,38 @@ class AgentConfig:
208209
cache_tools: bool = True
209210

210211

212+
# Model-loading options belong to the engine (load_model), not the agent. Passing
213+
# one straight to AgentConfig otherwise raises a cryptic dataclass
214+
# "unexpected keyword argument 'engine'"; intercept it with an actionable hint.
215+
_MODEL_LOAD_KWARGS = frozenset({
216+
"engine", "engine_config", "tensor_parallel_size", "gpu_memory_utilization",
217+
"apply_chat_template", "quantization", "trust_remote_code",
218+
})
219+
220+
221+
def _agentconfig_init_guard(_dataclass_init):
222+
"""Wrap AgentConfig.__init__ to translate a stray model-loading kwarg into a
223+
one-line "here's how to do it" instead of a bare dataclass TypeError."""
224+
@functools.wraps(_dataclass_init)
225+
def __init__(self, *args: Any, **kwargs: Any) -> None:
226+
bad = _MODEL_LOAD_KWARGS.intersection(kwargs)
227+
if bad:
228+
opt = sorted(bad)
229+
raise TypeError(
230+
f"AgentConfig does not accept model-loading option(s) {opt}: "
231+
"they configure the engine, not the agent. Either load the model "
232+
"first — load_model(model_id, engine=\"transformers\") — and pass "
233+
"the instance as model=, or use "
234+
"create_agent(preset, model_id, engine=\"transformers\"), which "
235+
"routes these to load_model for you."
236+
)
237+
_dataclass_init(self, *args, **kwargs)
238+
return __init__
239+
240+
241+
AgentConfig.__init__ = _agentconfig_init_guard(AgentConfig.__init__)
242+
243+
211244
@dataclass
212245
class AgentResponse:
213246
"""

effgen/core/agent_generation.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -447,17 +447,24 @@ def _accumulate_run_cost(self, result_metadata: dict[str, Any] | None) -> None:
447447
accum["calls"] = accum.get("calls", 0) + 1
448448

449449
def _finalize_cost_metadata(self, response: AgentResponse) -> None:
450-
"""Fold the per-run cost accumulator onto ``response.metadata``.
450+
"""Fold per-run latency + the cost accumulator onto ``response.metadata``.
451451
452-
Surfaces ``cost_usd`` and ``prompt_tokens`` / ``completion_tokens`` /
453-
``total_tokens`` for the whole run (every model call summed, tool loops
454-
included). Existing keys win, so a path that already set its own cost is
452+
Surfaces ``latency_ms`` / ``duration_s`` (always available) and, when the
453+
run made billable model calls, ``cost_usd`` plus ``prompt_tokens`` /
454+
``completion_tokens`` / ``total_tokens`` (every call summed, tool loops
455+
included). Existing keys win, so a path that already set its own value is
455456
left untouched. Cost is rounded to whole microdollars.
456457
"""
458+
meta = response.metadata
459+
# Per-run latency, mirrored from execution_time so callers can read it
460+
# off metadata alongside cost/tokens — no need to wrap each call in a
461+
# timer. (The eval layer already measures the same number.)
462+
if response.execution_time:
463+
meta.setdefault("latency_ms", round(response.execution_time * 1000.0, 1))
464+
meta.setdefault("duration_s", round(response.execution_time, 4))
457465
accum = getattr(self, "_run_cost_accum", None)
458466
if not accum or not accum.get("calls"):
459467
return
460-
meta = response.metadata
461468
if "cost_usd" in accum and "cost_usd" not in meta:
462469
meta["cost_usd"] = round(float(accum["cost_usd"]), 8)
463470
for key in ("prompt_tokens", "completion_tokens", "total_tokens"):

effgen/core/structured_output.py

Lines changed: 73 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -334,9 +334,21 @@ def structured_generate(
334334
if raw is not None:
335335
last_raw = raw
336336

337+
final_error = last_error or "could not produce schema-valid output"
338+
# Teachable failure: a local Transformers model that couldn't be coaxed into
339+
# schema-valid JSON by prompting alone can be *guaranteed* to conform with
340+
# grammar-constrained decoding — but only if ``outlines`` is installed. When
341+
# it isn't, point the user at the one-line fix instead of leaving a dead end.
342+
if _is_transformers_model(model) and not _outlines_available():
343+
final_error += (
344+
" — this local model did not follow the JSON schema by prompting. "
345+
"Install grammar-constrained decoding (`pip install effgen[grammar]`) "
346+
"for guaranteed schema-valid output, or use a stronger "
347+
"instruction-following model."
348+
)
337349
return StructuredOutcome(
338350
success=False, attempts=attempts, method="reprompt",
339-
error=last_error or "could not produce schema-valid output",
351+
error=final_error,
340352
raw_text=last_raw,
341353
)
342354

@@ -387,7 +399,22 @@ def _outlines_available() -> bool:
387399
Checks the concrete symbols ``_try_grammar_constrained`` relies on (not just
388400
that the package exists), so an incompatible ``outlines`` build is never
389401
counted as a real grammar attempt — keeping the surfaced attempt count honest.
402+
Supports both the current ``outlines`` 1.x API (``from_transformers`` +
403+
``Generator`` + ``types.JsonSchema``) and the legacy 0.0.x API
404+
(``generate.json`` + ``models.Transformers``).
390405
"""
406+
try:
407+
import outlines # noqa: F401
408+
except Exception:
409+
return False
410+
# outlines 1.x
411+
if hasattr(outlines, "from_transformers") and hasattr(outlines, "Generator"):
412+
try:
413+
from outlines.types import JsonSchema # noqa: F401
414+
except Exception:
415+
return False
416+
return True
417+
# legacy outlines 0.0.x
391418
try:
392419
from outlines import generate, models # noqa: F401
393420
except Exception:
@@ -571,30 +598,61 @@ def _reprompt_once(
571598
return None, None, text, f"schema validation failed: {err}"
572599

573600

601+
def _grammar_max_new_tokens(gen_kwargs: dict[str, Any]) -> int:
602+
"""Token budget for one grammar-constrained call.
603+
604+
Honours an explicit ``max_new_tokens``/``max_tokens`` from the caller so a
605+
long structured object isn't truncated mid-JSON; otherwise a sensible default
606+
that comfortably fits a typical extraction record.
607+
"""
608+
for key in ("max_new_tokens", "max_tokens"):
609+
val = gen_kwargs.get(key)
610+
if isinstance(val, int) and val > 0:
611+
return val
612+
return 512
613+
614+
574615
def _try_grammar_constrained(
575616
model: Any,
576617
prompt: str,
577618
schema: dict[str, Any],
578619
gen_kwargs: dict[str, Any],
579620
) -> tuple[str, Any] | None:
580-
"""Try grammar-constrained decoding via outlines library."""
621+
"""Try grammar-constrained decoding via the ``outlines`` library.
622+
623+
The output is structurally guaranteed to match *schema* in one call, so even
624+
weak instruction-followers (small Llama/Gemma/Phi locals) emit schema-valid
625+
JSON. Supports both the current ``outlines`` 1.x API and the legacy 0.0.x API;
626+
returns ``None`` (caller falls back to reprompting) if neither is usable.
627+
"""
628+
# outlines drives the underlying HF model + tokenizer directly.
629+
if not (hasattr(model, "model") and hasattr(model, "tokenizer")):
630+
return None
581631
try:
582-
import outlines # noqa: F401
632+
import outlines
633+
except ImportError:
634+
logger.debug("outlines not installed, skipping grammar-constrained decoding")
635+
return None
636+
try:
637+
logger.debug("Using outlines grammar-constrained decoding for structured output")
638+
if hasattr(outlines, "from_transformers") and hasattr(outlines, "Generator"):
639+
# outlines 1.x
640+
from outlines.types import JsonSchema
641+
642+
om = outlines.from_transformers(model.model, model.tokenizer)
643+
generator = outlines.Generator(om, JsonSchema(schema))
644+
raw = generator(prompt, max_new_tokens=_grammar_max_new_tokens(gen_kwargs))
645+
parsed = json.loads(raw) if isinstance(raw, str) else raw
646+
json_str = raw if isinstance(raw, str) else json.dumps(raw)
647+
return json_str, parsed
648+
# legacy outlines 0.0.x
583649
from outlines import generate
584650
from outlines import models as outlines_models
585651

586-
logger.debug("Using outlines grammar-constrained decoding for structured output")
587-
# outlines requires access to the underlying HF model
588-
if hasattr(model, 'model') and hasattr(model, 'tokenizer'):
589-
hf_model = outlines_models.Transformers(model.model, model.tokenizer)
590-
schema_str = json.dumps(schema)
591-
generator = generate.json(hf_model, schema_str)
592-
result = generator(prompt)
593-
# result is already a parsed object
594-
json_str = json.dumps(result)
595-
return json_str, result
596-
except ImportError:
597-
logger.debug("outlines not installed, skipping grammar-constrained decoding")
652+
hf_model = outlines_models.Transformers(model.model, model.tokenizer)
653+
generator = generate.json(hf_model, json.dumps(schema))
654+
result = generator(prompt)
655+
return json.dumps(result), result
598656
except Exception as e:
599657
logger.warning(f"Grammar-constrained decoding failed: {e}")
600658
return None

0 commit comments

Comments
 (0)