"""Unified ``HybridDecisionEngine`` API class — V4 via kwargs.
Implements the V4 pattern: an LLM elicits per-agent preference weights
(β, κ); a closed-form mixed-logit then computes discrete zone choice.
The library exposes this as a single first-class API class so that user
code instantiates one symbol regardless of the underlying implementation.
Internally delegates to :class:`AhlfeldtArgmaxHybridEngine`, ported from
the dev repo.
Example::
import agent_urban_planning as aup
engine = aup.HybridDecisionEngine(
params=scenario.ahlfeldt_params,
llm_client=aup.llm.CodexCliClient(),
cluster_k=50,
num_agents=1_000_000,
seed=42,
)
"""
from __future__ import annotations
from typing import Any
from agent_urban_planning.decisions.ahlfeldt_argmax_hybrid_engine import (
AhlfeldtArgmaxHybridEngine,
)
[docs]
class HybridDecisionEngine:
"""LLM-elicited preferences + closed-form mixed-logit choice (V4).
The hybrid pattern: an LLM is queried *once per agent cluster* to
elicit the cluster's per-agent preference weights (β: housing share;
κ: commute disutility). The closed-form mixed-logit choice then
computes discrete zone selection deterministically. This pattern
keeps the LLM in the role of *parameter provider* rather than
*decision maker* — much cheaper than the full-LLM V5 approach
while still capturing demographic-driven preference heterogeneity.
Args:
params: An ``AhlfeldtParams`` instance carrying structural
elasticities (``alpha``, ``beta``, ``epsilon``, ``kappa_eps``).
llm_client: An :class:`agent_urban_planning.llm.LLMClient`
instance (or anything with a ``.complete(user, system="")``
method returning a string).
**kwargs: Forwarded to the underlying
:class:`AhlfeldtArgmaxHybridEngine`. Common kwargs:
``cluster_k`` (default 50), ``clustering_algo`` (default
``"kmeans"``), ``num_agents``, ``batch_size``, ``seed``,
``llm_concurrency``, ``cache_dir``.
Raises:
ValueError: If ``llm_client`` is None.
Examples:
V4 reproduction with codex-cli::
>>> import agent_urban_planning as aup
>>> engine = aup.HybridDecisionEngine(
... params=scenario.ahlfeldt_params,
... llm_client=aup.llm.CodexCliClient(),
... cluster_k=50,
... num_agents=1_000_000,
... seed=42,
... )
>>> # sim = aup.SimulationEngine(scenario, agent_config, engine=engine)
>>> # results = sim.run()
V4 with claude-code (alternate provider)::
>>> engine = aup.HybridDecisionEngine(
... params=scenario.ahlfeldt_params,
... llm_client=aup.llm.ClaudeCodeClient(),
... )
See Also:
:class:`agent_urban_planning.UtilityEngine` — closed-form V1/V2/V3
baselines (no LLM involvement).
:class:`agent_urban_planning.LLMDecisionEngine` — V5 full-LLM-as-
decision-maker pattern.
"""
def __init__(
self,
params: Any,
elicitor: Any = None,
*,
llm_client: Any = None,
**kwargs: Any,
) -> None:
# V4's underlying engine takes a high-level "elicitor" object that
# wraps an LLM client + caching + per-type β/κ extraction. The simple
# `llm_client` argument is accepted for API symmetry with
# LLMDecisionEngine, but an elicitor must be provided for the engine
# to actually call the LLM. Users who only have an llm_client should
# construct an elicitor themselves; see the dev repo's
# `AhlfeldtElicitor` for the reference implementation.
if elicitor is None and llm_client is None:
raise ValueError(
"HybridDecisionEngine requires either an elicitor or an "
"llm_client; got neither. The V4 pattern uses an elicitor "
"to extract per-type preference weights from the LLM. See "
"aup.research.berlin for AhlfeldtElicitor (the reference "
"implementation)."
)
if elicitor is None:
raise NotImplementedError(
"HybridDecisionEngine currently requires a pre-built elicitor "
"object. Building one from a raw llm_client is supported in "
"the dev repo via simulator.decisions.elicitation but not yet "
"exposed in the public API. Pass `elicitor=` directly for now."
)
self._impl = AhlfeldtArgmaxHybridEngine(
params, elicitor=elicitor, **kwargs,
)
[docs]
def decide_batch(self, *args: Any, **kwargs: Any) -> Any:
"""Forward to the underlying implementation's ``decide_batch``.
Transparently forwards to
:meth:`AhlfeldtArgmaxHybridEngine.decide_batch`. Calling this
triggers (and caches) LLM elicitation of per-type preference
weights on the first invocation; subsequent calls reuse the
cached weights.
Args:
*args: Positional arguments forwarded unchanged.
**kwargs: Keyword arguments forwarded unchanged.
Returns:
List of :class:`agent_urban_planning.LocationChoice`, one
per input agent and in the same order.
Examples:
>>> import agent_urban_planning as aup
>>> # engine = aup.HybridDecisionEngine(params, elicitor=elicitor)
>>> # choices = engine.decide_batch(agents, env, zones, prices)
"""
return self._impl.decide_batch(*args, **kwargs)
def __getattr__(self, name: str) -> Any:
if name.startswith("_"):
raise AttributeError(name)
return getattr(self._impl, name)
def __repr__(self) -> str:
return f"HybridDecisionEngine(_impl={type(self._impl).__name__})"