"""The rules of ``modelly.lint`` and the AST helpers they share.
Codes below 100 are errors: the model runs but gives a wrong or unreproducible result.
Codes from 100 are warnings: the model is right but its cost grows faster than it needs to.
"""
from __future__ import annotations
import ast
from dataclasses import dataclass
GUIDE = "https://remipelloux.github.io/Modelly/lint.html"
AGENT_BASES = frozenset({"Agent", "CellAgent", "FixedAgent", "ContinuousSpaceAgent"})
MODEL_BASES = frozenset({"Model"})
# Methods of an AgentSet that visit every member.
SCANS = frozenset({"select", "agg", "groupby", "sort", "map"})
# numpy.random names that build a seeded generator rather than draw from the global one.
NUMPY_SEEDED = frozenset(
{"Generator", "PCG64", "Philox", "SFC64", "MT19937", "SeedSequence"}
)
STEP_COMPLEXITY_LIMIT = 12
# Calls whose value differs between two runs with the same seed.
CLOCK_CALLS = frozenset(
{
"time.time",
"time.time_ns",
"datetime.datetime.now",
"datetime.datetime.utcnow",
"datetime.datetime.today",
"datetime.date.today",
"uuid.uuid1",
"uuid.uuid4",
"os.urandom",
"os.getrandom",
}
)
ENTROPY_MODULES = frozenset({"secrets"})
# pandas calls that build a whole new frame.
FRAME_BUILDERS = frozenset({"pandas.DataFrame", "pandas.concat"})
# AgentSet methods that change its membership.
MUTATORS = frozenset({"add", "discard", "remove", "clear"})
# HTTP and provider SDK modules whose calls reach the network outside modelly.llm.
NETWORK_MODULES = frozenset({"requests", "httpx", "openai", "anthropic"})
NETWORK_CALLS = frozenset({"urllib.request.urlopen"})
# Method chains of provider SDK clients, caught even on a client held in an attribute.
SDK_CALL_CHAINS = (
("chat", "completions", "create"),
("completions", "create"),
("messages", "create"),
("responses", "create"),
)
[docs]
@dataclass(frozen=True)
class Rule:
"""One check: its code, how serious it is, what it found and what to do instead."""
code: str
severity: str
message: str
hint: str
RULES = {
rule.code: rule
for rule in (
Rule(
"MDL001",
"error",
"random draw outside the model's generators",
"use self.random (stdlib API) or self.rng (numpy Generator) so the seed reproduces the run",
),
Rule(
"MDL002",
"error",
"__init__ does not call super().__init__",
"call super().__init__(...) first: without it the agent is never registered with the model",
),
Rule(
"MDL003",
"error",
"loop over a set: the order changes from run to run",
"iterate an AgentSet, a list, or sorted(...) so the same seed gives the same run",
),
Rule(
"MDL004",
"error",
"mutable class attribute on an agent is shared by every agent",
"create it in __init__ (self.items = [])",
),
Rule(
"MDL005",
"error",
"wall clock or OS entropy in model code: the value differs between runs",
"draw from self.random / self.rng, or use the model's time (self.time) for timestamps",
),
Rule(
"MDL006",
"error",
"hash() or id() decides an outcome: it changes between runs",
"order by unique_id or an attribute; derive seeds from the model's generator",
),
Rule(
"MDL007",
"error",
"a loop over the population removes agents: removed ones are still visited",
"use agents.do / shuffle_do (they skip removed agents) or check `agent in model.agents`",
),
Rule(
"MDL008",
"error",
"global statement in a model or agent method: every model in the process shares it",
"keep the state on the model (self.model.counter) so each run starts fresh",
),
Rule(
"MDL009",
"error",
"the model's agent set is changed directly: its bookkeeping is skipped",
"create agents with Agent(model) / create_agents, and remove them with agent.remove()",
),
Rule(
"MDL010",
"error",
"a lambda or nested function is sent to worker processes: spawn cannot import it",
"define the function or class at module level in an importable file (Windows and macOS start workers with spawn)",
),
Rule(
"MDL012",
"error",
"network or language-model call outside modelly.llm: the run cannot be replayed",
"call through modelly.llm (self.decide / self.model.llm), which caches every reply",
),
Rule(
"MDL101",
"warning",
"an agent scans the whole population: O(N) per agent, O(N^2) per step",
"look at neighbours through self.cell.neighborhood or a space query, or move the phase to the model as columns",
),
Rule(
"MDL102",
"warning",
"nested loops over the population: O(N^2) per step",
"pair agents through the space (cells, pairs_within) or group them first (agents.groupby)",
),
Rule(
"MDL103",
"warning",
"hand-written activation loop",
'use self.agents.shuffle_do("step") (random order) or self.agents.do("step") (fixed order)',
),
Rule(
"MDL104",
"warning",
"deepcopy inside step",
"copy only the fields you need, or keep the previous state in a column",
),
Rule(
"MDL105",
"warning",
f"step has more than {STEP_COMPLEXITY_LIMIT} branches",
"split it into named phases (move, interact, update) and call them in order",
),
Rule(
"MDL106",
"warning",
"pandas DataFrame built inside step",
"record with a DataCollector, or append rows to a list and build the frame once",
),
Rule(
"MDL107",
"warning",
"per-agent loop that only writes Column attributes",
"update the whole column at once: table = self.columns[Cls]; table.x += 1",
),
)
}
[docs]
def dotted(node: ast.AST) -> list[str]:
"""Return the names along an attribute chain: ``self.model.agents`` -> self, model, agents."""
parts: list[str] = []
while True:
if isinstance(node, ast.Attribute):
parts.append(node.attr)
node = node.value
elif isinstance(node, ast.Subscript):
node = node.value
elif isinstance(node, ast.Call):
node = node.func
else:
break
if isinstance(node, ast.Name):
parts.append(node.id)
return parts[::-1]
[docs]
def is_population(node: ast.AST, in_model: bool) -> bool:
"""Whether ``node`` is the model's whole population or one type of it, not a cell's agents."""
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
if node.func.attr in SCANS | {"shuffle", "to_list", "copy"}:
return is_population(node.func.value, in_model)
if node.func.attr == "values" and dotted(node.func.value)[-1:] == [
"agents_by_type"
]:
return True
if isinstance(node, ast.Subscript):
return dotted(node.value)[-1:] == ["agents_by_type"]
chain = dotted(node)
if not isinstance(node, ast.Attribute) or chain[-1:] != ["agents"]:
return False
return "model" in chain or (in_model and chain == ["self", "agents"])
[docs]
def is_set_expr(node: ast.AST) -> bool:
"""Whether ``node`` builds a set, whose iteration order follows memory addresses."""
if isinstance(node, ast.Set | ast.SetComp):
return True
return (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "set"
)
[docs]
def is_mutable_literal(node: ast.AST | None) -> bool:
"""Whether ``node`` is ``[]``, ``{}``, a set literal or an empty list/dict/set call."""
if isinstance(
node, ast.List | ast.Dict | ast.Set | ast.ListComp | ast.DictComp | ast.SetComp
):
return True
return (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id in {"list", "dict", "set"}
)
[docs]
def calls_super_init(func: ast.FunctionDef) -> bool:
"""Whether ``func`` calls ``super().__init__`` or ``<Base>.__init__(self, ...)``."""
for node in ast.walk(func):
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
continue
if node.func.attr != "__init__":
continue
target = node.func.value
if isinstance(target, ast.Call) and getattr(target.func, "id", None) == "super":
return True
if isinstance(target, ast.Name | ast.Attribute):
return True
return False
[docs]
def branches(func: ast.FunctionDef) -> int:
"""Count the decision points of ``func``: its cyclomatic complexity minus one."""
count = 0
for node in ast.walk(func):
if isinstance(
node, ast.If | ast.For | ast.While | ast.IfExp | ast.ExceptHandler
):
count += 1
elif isinstance(node, ast.BoolOp):
count += len(node.values) - 1
elif isinstance(node, ast.comprehension):
count += 1 + len(node.ifs)
return count