From 9d4fc618ed15f3922eb1d7810d21190e279b4321 Mon Sep 17 00:00:00 2001 From: dongyun Date: Fri, 28 Aug 2026 21:16:22 +0000 Subject: [PATCH 01/18] feat(flows): add checking -- the static read of a flow's legality Pure ast over every file a flow holds, executing nothing, answering with findings rather than raising: ten errors for a flow that cannot run, cannot be answered or cannot end, and eight warnings for a run that may be regretted. surface() reads the flow-facing interfaces themselves -- lifted from the interface test, which now imports it -- and offered() reads the package's own tables, so the checker states the contract it checks rather than keeping a copy to drift. Every rule is the proof of an absence, one function at a time, and the sweep over the builtin and official flows pins the false-positive rate: nothing anywhere, except the one warning rlar has earned -- a loop only its reviewer can end. Co-Authored-By: Claude Fable 5 --- src/hmz/flows/SPEC.md | 53 +- src/hmz/flows/__init__.py | 3 + src/hmz/flows/checking.py | 1559 ++++++++++++++++++++++++++++++++++ tests/test_checking.py | 842 ++++++++++++++++++ tests/test_flow_interface.py | 27 +- 5 files changed, 2458 insertions(+), 26 deletions(-) create mode 100644 src/hmz/flows/checking.py create mode 100644 tests/test_checking.py diff --git a/src/hmz/flows/SPEC.md b/src/hmz/flows/SPEC.md index f163a2d..f2023d8 100644 --- a/src/hmz/flows/SPEC.md +++ b/src/hmz/flows/SPEC.md @@ -7,6 +7,7 @@ ├── __init__.py ├── agent.py ├── builtin +├── checking.py ├── driving.py ├── skills.py └── verses.py @@ -164,8 +165,9 @@ def __getattr__(name: str) -> object: ... flow -- one phase of a thing, an engine two flows share -- is a flow to call by name and not a flow to start, and one that appeared in the picker would be a line nobody can act on. - This module MUST be everything a flow imports, which is the interfaces beside it, the mark, - the finding, the calling, and what is written down in another layer handed through: the - vocabulary a turn is described in, the facts about the CLIs and what each of them runs, and + the finding, the calling, the checking, and what is written down in another layer handed + through: the vocabulary a turn is described in, the facts about the CLIs and what each of + them runs, and where humanize keeps what outlives a run. What is handed through MUST be the same object the layer it is written in holds, so that a flow and humanize are talking about one thing. - What is handed through MUST be fetched when it is asked for. Importing this module MUST cost @@ -428,6 +430,53 @@ run another. `hmz.runner` asks this and then opens a cycle around the answer. than take one as it comes: a flow is loaded by running its file, so the class it declared last time is a stranger to the class it declares this time, and what survives that is the fields. +## `checking.py` + +```python +class Finding(NamedTuple): + code: str + severity: Literal["error", "warning"] + where: Path + line: int + said: str + + +def checked(flow: str | os.PathLike[str]) -> tuple[Finding, ...]: ... + + +def surface(protocol: type) -> frozenset[str]: ... + + +def offered() -> frozenset[str]: ... +``` + +The static read of a flow's legality: what will not run, said before anything runs it. +`driving.py` refuses a flow as it loads it, and loading a flow means running its file -- so +this is the reading for a flow nobody has read yet, generated or fetched or forked, and it is +the first of two: what only running the file can show is `proving.py`'s, in a process of its +own. + +- `checked` MUST NOT import or execute anything of the flow it reads. It is pointed at + untrusted code -- that is what it is for -- and a checker that ran what it was checking + would be the attack it exists to catch. Every file the flow's directory holds MUST be read, + except what is under its `skills/`, which is content for the agents rather than code. +- It MUST answer with findings rather than raise, and every finding MUST carry a code, a + severity, a file and a line: a checker is asked so that everything wrong can be said at + once, and a finding that cannot say where it is is a finding nobody can act on. +- `error` MUST be kept for a flow that cannot run, cannot be answered, or cannot end -- + something no run of it survives -- and `warning` for a flow that runs and may be regretted. + A flow with no error findings MUST be one `driving.py` would load, as far as reading can + tell; nothing here MUST refuse a flow for style. +- Every rule MUST be the proof of an absence, worked out one function at a time: no exit in + this loop, no bound in this function, no guard on this name. Nothing MUST claim an exit + reachable or a bound tight, and nothing MUST follow a value through a call -- a flow that + keeps its loop in one function and its bound in another is a flow this reading trusts, + since a rule that guessed further would refuse flows that run. +- What an agent may be asked MUST be read off the interfaces in `agent.py` themselves, which + is `surface`, and what a flow may import MUST be read off this package's own tables, which + is `offered`: the checker states what the interface is, so a second copy of either would be + the drift it checks for. + ## `verses.py` ```python diff --git a/src/hmz/flows/__init__.py b/src/hmz/flows/__init__.py index 127ee74..115ee9c 100644 --- a/src/hmz/flows/__init__.py +++ b/src/hmz/flows/__init__.py @@ -52,6 +52,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: from typing import TYPE_CHECKING, Any, NamedTuple, overload from .agent import Agent, Driven, Person, Session +from .checking import Finding, checked from .driving import ( NotAFlow, Place, @@ -127,6 +128,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "Driven", "Event", "Failed", + "Finding", "Flow", "Flowverse", "Goal", @@ -156,6 +158,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "at", "backends", "carries", + "checked", "configures", "container", "drives", diff --git a/src/hmz/flows/checking.py b/src/hmz/flows/checking.py new file mode 100644 index 0000000..edcbf91 --- /dev/null +++ b/src/hmz/flows/checking.py @@ -0,0 +1,1559 @@ +"""The static read of a flow's legality: what will not run, said before anything runs it. + +:mod:`hmz.flows.driving` refuses a flow as it loads it -- the wrong arity, a moment no agent +can run -- but loading a flow means running its file, and the flow most worth checking is one +nobody has read yet: generated, fetched, forked and edited. This is the reading that executes +nothing. Pure `ast` over every Python file the flow's directory holds, answering with findings +rather than raising, so that whatever asked can say everything that is wrong at once. + +What a severity means is one line apiece. An error is a flow that cannot run, cannot be +answered, or cannot end -- something no run of it survives. A warning is a flow that runs, +and a run of it that may be regretted: a loop with no bound of its own, a shaped answer read +without a guard, a config that takes anything. + +And what the reading does not claim is said as plainly. Every rule is the proof of an +absence -- no exit in this loop, no bound in this function, no guard on this name -- worked +out one function at a time. Nothing here proves an exit reachable or a bound tight, and +nothing follows a value through a call: a flow that keeps its loop in one function and its +bound in another is a flow this reading trusts, and a checker that guessed further would +refuse flows that run. +""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Literal, NamedTuple, Protocol + +from .agent import Agent, Driven, Person, Session + +if TYPE_CHECKING: + import os + from collections.abc import Iterator + +__all__ = ["Finding", "checked", "offered", "surface"] + + +class Finding(NamedTuple): + """One thing the reading of a flow found, where it found it. + + Attributes: + code: Which rule found it, as one hyphenated word -- `dead-loop`, `unknown-ask`. + severity: What finding it is. An error is a flow that cannot run, cannot be answered + or cannot end; a warning is a flow that runs and may be regretted. + where: The file it is in. + line: The line, 1-based, or 0 for a finding about the whole file. + said: What is wrong, said the way `NotAFlow` says it. + """ + + code: str + severity: Literal["error", "warning"] + where: Path + line: int + said: str + + +def surface(protocol: type) -> frozenset[str]: + """What one of the flow-facing interfaces asks for, by name. + + The one reading of a protocol's members, shared by the checker's own rules and by the + tests that hold the drivers to the same interfaces -- two readings of what an agent + answers to would be two readings to drift apart. + + Args: + protocol: The interface. + + Returns: + One name per member it declares, its own and whatever it is itself an interface of. + `__call__` is among them where it is declared, an agent and a session both being things + a flow calls; the rest of the dunders are Python's and are not part of the contract. + """ + said: set[str] = set() + for one in protocol.__mro__: + if one in (object, Protocol): + continue + held = set(vars(one)) | set(getattr(one, "__annotations__", {})) + said.update( + name for name in held if not name.startswith("_") or name == "__call__" + ) + return frozenset(said) + + +def offered() -> frozenset[str]: + """Every name a flow may import from `hmz.flows`, which is the whole of its vocabulary. + + What the module says it offers rather than what happens to be reachable on it: a name + that works today because a submodule leaked it is a name the next release takes away. + + Returns: + The names: what `__all__` declares, what is handed through by name from the layers a + flow does not import, and the two modules handed through whole. + """ + # The package's own tables, read by the package's own checker: private to every + # flow, and one copy rather than a second one kept here to drift. + from . import _ELSEWHERE, _MODULES # pyright: ignore[reportPrivateUsage] + from . import __all__ as declared + + return frozenset(declared) | frozenset(_ELSEWHERE) | frozenset(_MODULES) + + +def checked(flow: str | os.PathLike[str]) -> tuple[Finding, ...]: + """Reads a flow without running it, and answers with everything that reading found. + + Every Python file the flow's directory holds is read -- the entry point and whatever it + imports beside it -- except what is under its `skills/`, which is content for the agents + rather than code this process runs. Nothing is imported and nothing is executed, so this + is safe to point at a flow nobody has read: what running the file would refuse is the + second reading, :mod:`hmz.flows.proving`, which runs it in a process of its own. + + Args: + flow: The flow: its directory, or the Python file a single-file flow is. + + Returns: + One finding per thing found, in file order, and nothing at all for a flow this reading + has nothing to say about -- which is not a proof, only a reading with nothing to say. + """ + from . import ENTRY + + at = Path(flow) + if at.is_dir(): + entry = at / ENTRY + files = [ + one + for one in sorted(at.rglob("*.py")) + if "__pycache__" not in one.relative_to(at).parts + # The skills are content: one directory per skill, laid out the way every one + # of these CLIs reads a skill in, and nothing in one is imported by the flow. + and one.relative_to(at).parts[0] != "skills" + ] + else: + entry = at + files = [at] if at.is_file() else [] + if not entry.is_file(): + return ( + Finding( + "not-a-flow", + "error", + at, + 0, + "no flow to read: a flow is a directory with an __init__.py in it", + ), + ) + + found: list[Finding] = [] + read: list[_Read] = [] + for one in files: + held = _parsed(one) + if isinstance(held, Finding): + found.append(held) + else: + read.append(held) + + marked = any(one.marks for one in read) + entered = next((one for one in read if one.where == entry), None) + if entered is not None and not marked: + found.append( + Finding( + "not-a-flow", + "error", + entry, + 0, + "nothing in it is marked @flow() -- a flow is a function marked with it, " + "which is how a file says which of the functions in it is one", + ) + ) + if entered is not None and ast.get_docstring(entered.tree) is None: + found.append( + Finding( + "unsaid-flow", + "warning", + entry, + 0, + "the flow says nothing about itself -- the first line of this file's " + "docstring is what every list of flows shows for it", + ) + ) + + # What the flow declared about moments anywhere in its files, for the hooks it hangs: + # a place annotated in the entry point covers a hook hung in the module beside it. + declared = frozenset(moment for one in read for moment in one.moments_declared) + asks = _Asks() + for one in read: + found.extend(_imports(one)) + found.extend(_marks(one)) + found.extend(_hooks(one, declared)) + found.extend(_functions(one, asks)) + return tuple(found) + + +# --------------------------------------------------------------------------------------- +# Reading one file: what it imports, what it marks, and what it declares. +# --------------------------------------------------------------------------------------- + +#: The kinds of thing a flow drives, each the name of one flow-facing interface. What a +#: tracked name may be asked is read off the interface itself, so the checker and the +#: drivers are held to one surface. +_KINDS: dict[str, type] = { + "agent": Agent, + "person": Person, + "session": Session, + "driven": Driven, +} + +#: How the local names for those interfaces read where a flow imports them. +_PROTOCOLS = { + "Agent": "agent", + "Person": "person", + "Session": "session", + "Driven": "driven", +} + +#: What an element of a plain tuple of agents may still be asked by name: the tuple's own +#: two methods. A named place is a field of the NamedTuple the flow declared instead. +_OF_A_TUPLE = frozenset({"count", "index"}) + + +class _Mark(NamedTuple): + """One function a file marked with `@flow`, and what the mark said.""" + + node: ast.FunctionDef | ast.AsyncFunctionDef + name: str + resumable: bool + + +@dataclass +class _Read: + """One file, parsed, and what one pass over its top collected.""" + + where: Path + tree: ast.Module + #: The local names of :func:`hmz.flows.flow`, `Moment` and pydantic's `Field`. + flow_alias: set[str] = field(default_factory=set[str]) + moment_alias: set[str] = field(default_factory=set[str]) + field_alias: set[str] = field(default_factory=set[str]) + #: Local name -> which interface, for every flow-facing interface the file imports. + proto: dict[str, str] = field(default_factory=dict[str, str]) + #: The NamedTuple and pydantic model classes the file itself declares. + crews: dict[str, ast.ClassDef] = field(default_factory=dict[str, ast.ClassDef]) + models: dict[str, ast.ClassDef] = field(default_factory=dict[str, ast.ClassDef]) + #: Names bound only under `if TYPE_CHECKING:`, which a running flow cannot read. + unread: set[str] = field(default_factory=set[str]) + #: Every name the file binds at module level as it runs, which excuses the above. + bound: set[str] = field(default_factory=set[str]) + marks: list[_Mark] = field(default_factory=list["_Mark"]) + #: Every `Moment.X` written inside an annotation, which is a flow declaring a need. + moments_declared: set[str] = field(default_factory=set[str]) + + +def _parsed(where: Path) -> _Read | Finding: + """One file read into a tree, or the finding that it could not be. + + Args: + where: The file. + + Returns: + What was read, or an `unread` error: a file that will not parse is a flow that will + not load, said here rather than left for the loading to hit. + """ + try: + tree = ast.parse(where.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, SyntaxError, ValueError) as why: + line = getattr(why, "lineno", 0) or 0 + return Finding( + "unread", + "error", + where, + line, + f"nothing here can be read as Python -- {why}", + ) + read = _Read(where, tree) + _collected(read, tree.body, type_checking=False) + # Bound under TYPE_CHECKING and nowhere else: a name a type checker reads and a + # running flow cannot, which is the one thing `unread-annotation` is about. + read.unread -= read.bound + for node in ast.walk(tree): + if isinstance(node, (ast.AnnAssign, ast.arg)) and node.annotation is not None: + read.moments_declared.update(_moments_in(node.annotation, read)) + return read + + +def _collected(read: _Read, body: list[ast.stmt], *, type_checking: bool) -> None: + """Walks one file's statements for what the rules read off its top. + + Args: + read: What is being collected into. + body: The statements, at whatever depth the walk has reached. + type_checking: Whether these statements are under `if TYPE_CHECKING:`, where a name + is bound for a type checker and for nothing that runs. + """ + for node in body: + if isinstance(node, ast.ImportFrom) and node.module == "hmz.flows": + for alias in node.names: + bound = alias.asname or alias.name + if type_checking: + read.unread.add(bound) + continue + read.bound.add(bound) + if alias.name == "flow": + read.flow_alias.add(bound) + elif alias.name == "Moment": + read.moment_alias.add(bound) + elif alias.name in _PROTOCOLS: + read.proto[bound] = _PROTOCOLS[alias.name] + elif isinstance(node, (ast.Import, ast.ImportFrom)): + for alias in node.names: + bound = (alias.asname or alias.name).split(".")[0] + (read.unread if type_checking else read.bound).add(bound) + if ( + isinstance(node, ast.ImportFrom) + and node.module == "pydantic" + and alias.name == "Field" + and not type_checking + ): + read.field_alias.add(alias.asname or alias.name) + elif isinstance(node, (ast.Assign, ast.AnnAssign)) and not type_checking: + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + read.bound.update(one.id for one in targets if isinstance(one, ast.Name)) + elif isinstance(node, ast.ClassDef) and not type_checking: + read.bound.add(node.name) + bases = {_root(base) for base in node.bases} + if "NamedTuple" in bases: + read.crews[node.name] = node + elif "BaseModel" in bases or bases & set(read.models): + read.models[node.name] = node + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if not type_checking: + read.bound.add(node.name) + mark = _marked(node, read) + if mark is not None and not type_checking: + read.marks.append(mark) + elif isinstance(node, ast.If): + under = type_checking or _root(node.test) == "TYPE_CHECKING" + _collected(read, node.body, type_checking=under) + _collected(read, node.orelse, type_checking=type_checking) + elif isinstance(node, (ast.Try, ast.With)): + _collected(read, node.body, type_checking=type_checking) + + +def _marked(node: ast.FunctionDef | ast.AsyncFunctionDef, read: _Read) -> _Mark | None: + """The mark on one function, where it carries one. + + Args: + node: The function. + read: The file it is in, for what the decorator is called there. + + Returns: + The mark, or None for a function that is not a flow. + """ + for one in node.decorator_list: + if isinstance(one, ast.Name) and one.id in read.flow_alias: + return _Mark(node, "", resumable=False) + if ( + isinstance(one, ast.Call) + and isinstance(one.func, ast.Name) + and one.func.id in read.flow_alias + ): + name = "" + resumable = False + for said in one.keywords: + if said.arg == "name" and isinstance(said.value, ast.Constant): + name = str(said.value.value) + elif said.arg == "resumable" and isinstance(said.value, ast.Constant): + resumable = bool(said.value.value) + return _Mark(node=node, name=name, resumable=resumable) + return None + + +def _root(node: ast.expr) -> str: + """The name at the root of one expression, or "" where there is none.""" + while isinstance(node, ast.Attribute): + node = node.value + return node.id if isinstance(node, ast.Name) else "" + + +def _moments_in(annotation: ast.expr, read: _Read) -> set[str]: + """Every moment one annotation names, which is a place saying what it needs.""" + return { + node.attr + for node in ast.walk(annotation) + if isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in read.moment_alias + } + + +# --------------------------------------------------------------------------------------- +# The import rules: one import, and only the names it offers. +# --------------------------------------------------------------------------------------- + + +def _imports(read: _Read) -> Iterator[Finding]: + """What one file imports of humanize's, held to the one import a flow writes. + + Args: + read: The file. + + Yields: + A `foreign-import` error per import of a module of humanize's own that is not + `hmz.flows`, and an `unknown-name` error per name asked of `hmz.flows` that it does + not offer. + """ + offers: frozenset[str] | None = None + for node in ast.walk(read.tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.split(".")[0] == "hmz" and alias.name != "hmz.flows": + yield Finding( + "foreign-import", + "error", + read.where, + node.lineno, + f"a flow imports hmz.flows and nothing else of humanize's -- " + f"{alias.name} is humanize's own business, and a flow that names " + "it breaks whenever humanize moves it", + ) + elif isinstance(node, ast.ImportFrom) and node.module: + said = node.module + if said.split(".")[0] != "hmz": + continue + if said != "hmz.flows": + yield Finding( + "foreign-import", + "error", + read.where, + node.lineno, + f"a flow imports hmz.flows and nothing else of humanize's -- " + f"{said} is humanize's own business, and a flow that names it " + "breaks whenever humanize moves it", + ) + continue + if offers is None: + offers = offered() + for alias in node.names: + if alias.name not in offers: + yield Finding( + "unknown-name", + "error", + read.where, + node.lineno, + f"hmz.flows does not offer {alias.name!r} -- what a flow may " + "import from it is what it hands through, and a name it does " + "not hold fails at the first run", + ) + + +# --------------------------------------------------------------------------------------- +# The mark rules: what the entry point declares, read as the loader will read it. +# --------------------------------------------------------------------------------------- + +#: How many arguments a resumable flow's entry point takes at the least: the agents, the +#: task, and somewhere for what the last run of it wrote to be handed back in. +_WITH_A_STATE = 3 + +#: The one sentence the loader says about a flow that does not state its arity, said here +#: too so that the two readings refuse the same flow in the same words. +_UNSIZED = ( + "a flow is a function marked @flow() taking (agents, task), whose agents are " + "annotated with a tuple of a fixed length -- how many agents the flow drives -- or " + "with a NamedTuple of them, which also says what each is for" +) + + +def _marks(read: _Read) -> Iterator[Finding]: + """What each flow one file marks says about itself, held to what a run needs said. + + Args: + read: The file. + + Yields: + `unsized-agents` and `unread-annotation` errors for an arity a run cannot read back, + `stateless-resume` for a flow that says it can be picked up and takes nothing to be + picked up with, `twice-named` for two flows under one name, `state-kept` for kept + state nothing ever clears, and the config findings for the model an entry declares. + """ + named: set[str] = set() + configured: set[str] = set() + for mark in read.marks: + if mark.name in named: + yield Finding( + "twice-named", + "warning", + read.where, + mark.node.lineno, + f"two flows here are both {_said_name(mark.name)} -- the first of them " + "wins, and the second can never be run", + ) + named.add(mark.name) + yield from _sized(mark, read) + yield from _resumes(mark, read) + for model in _settings(mark.node, read): + if model not in configured: + configured.add(model) + yield from _configured(read.models[model], read) + + +def _said_name(name: str) -> str: + """How a flow's name reads in a finding about two of them.""" + return f"called {name!r}" if name else "the one their file holds under its own name" + + +def _sized(mark: _Mark, read: _Read) -> Iterator[Finding]: + """Whether one flow states how many agents it drives where a run can read it back.""" + args = mark.node.args + params = [*args.posonlyargs, *args.args] + kind = params[0].annotation if params else None + if kind is None: + yield Finding("unsized-agents", "error", read.where, mark.node.lineno, _UNSIZED) + return + unread = sorted(_names_in(kind) & read.unread) + if unread: + yield Finding( + "unread-annotation", + "error", + read.where, + mark.node.lineno, + f"the flow's agents cannot be read here ({', '.join(unread)} is imported " + "under TYPE_CHECKING) -- import what the annotation names at runtime, so " + "the count it states can be checked", + ) + return + said = _unquoted(kind) + if said is None: + return + if isinstance(said, ast.Name) and said.id == "tuple": + yield Finding( + "unsized-agents", + "error", + read.where, + mark.node.lineno, + _UNSIZED, + ) + return + if ( + isinstance(said, ast.Subscript) + and _root(said.value) == "tuple" + and any( + isinstance(one, ast.Constant) and one.value is Ellipsis + for one in _elements(said.slice) + ) + ): + yield Finding( + "unsized-agents", + "error", + read.where, + mark.node.lineno, + _UNSIZED, + ) + + +def _resumes(mark: _Mark, read: _Read) -> Iterator[Finding]: + """Whether a flow that says it can be picked up takes anything to be picked up with.""" + if not mark.resumable: + return + args = mark.node.args + params = [*args.posonlyargs, *args.args] + taken = len(params) + third = params[2].annotation if taken >= _WITH_A_STATE else None + settles = third is not None and bool(_names_in(third) & set(read.models)) + if taken < _WITH_A_STATE or (taken == _WITH_A_STATE and settles): + yield Finding( + "stateless-resume", + "error", + read.where, + mark.node.lineno, + "the flow says it can be picked up, and takes nothing to be picked up with " + "-- a resumable flow is handed a dict as its last argument, holding what it " + "wrote there last time", + ) + return + yield from _kept(mark, read, params[-1].arg) + + +def _kept(mark: _Mark, read: _Read, state: str) -> Iterator[Finding]: + """Whether kept state something writes is state anything ever clears. + + Args: + mark: The resumable flow. + read: Its file. + state: What its entry point calls the dict it is handed. + """ + held = {state} + wrote = 0 + cleared = False + for node in ast.walk(mark.node): + if isinstance(node, ast.Assign) and len(node.targets) == 1: + target = node.targets[0] + if isinstance(target, ast.Name) and any( + isinstance(one, ast.Name) and one.id in held + for one in ast.walk(node.value) + ): + held.add(target.id) + elif isinstance(node, ast.Subscript) and isinstance(node.ctx, ast.Store): + if _root(node.value) in held: + wrote = wrote or node.lineno + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and _root(node.func.value) in held + ): + if node.func.attr in {"update", "setdefault"}: + wrote = wrote or node.lineno + elif node.func.attr == "clear": + cleared = True + if wrote and not cleared: + yield Finding( + "state-kept", + "warning", + read.where, + wrote, + "the flow writes its kept state and never clears it -- a run that is over " + "leaves what the next run here opens on, so a loop that has ended clears " + "what it kept", + ) + + +def _settings( + node: ast.FunctionDef | ast.AsyncFunctionDef, read: _Read +) -> Iterator[str]: + """The config models one entry point declares, of the ones its own file holds.""" + args = node.args + for param in [*args.posonlyargs, *args.args][2:]: + if param.annotation is not None: + yield from ( + name for name in _names_in(param.annotation) if name in read.models + ) + + +def _configured(model: ast.ClassDef, read: _Read) -> Iterator[Finding]: + """One config model, held to refusing what it does not take and saying what it does. + + Args: + model: The model a flow's entry point says it can be set up with. + read: The file it is declared in. + + Yields: + A `loose-config` warning for one that takes anything, and an `unsaid-field` warning + per field that says nothing about itself -- the descriptions are what whoever sets + the flow up is shown, and a field without one is a question nobody can answer. + """ + if not _strict(model, read, set()): + yield Finding( + "loose-config", + "warning", + read.where, + model.lineno, + "the config takes anything -- set model_config to extra: forbid or frozen: " + "True, so a setting that is misspelled is refused rather than quietly " + "ignored", + ) + for node in model.body: + if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name): + continue + name = node.target.id + if name.startswith("_") or _root(node.annotation) == "ClassVar": + continue + bare = node.value is None + unsaid = ( + isinstance(node.value, ast.Call) + and _root(node.value.func) in read.field_alias + and not any(one.arg == "description" for one in node.value.keywords) + ) + if bare or unsaid: + yield Finding( + "unsaid-field", + "warning", + read.where, + node.lineno, + f"the config field {name!r} says nothing about itself -- give it a " + "Field(description=...), which is what whoever sets the flow up is " + "shown", + ) + + +def _strict(model: ast.ClassDef, read: _Read, seen: set[str]) -> bool: + """Whether one config model refuses what it does not take, its local bases included.""" + seen.add(model.name) + for node in model.body: + if ( + isinstance(node, ast.Assign) + and any( + isinstance(one, ast.Name) and one.id == "model_config" + for one in node.targets + ) + and isinstance(node.value, ast.Dict) + ): + said = { + key.value: value.value + for key, value in zip(node.value.keys, node.value.values, strict=True) + if isinstance(key, ast.Constant) and isinstance(value, ast.Constant) + } + if said.get("extra") == "forbid" or said.get("frozen") is True: + return True + return any( + _strict(read.models[base], read, seen) + for base in {_root(one) for one in model.bases} + if base in read.models and base not in seen + ) + + +# --------------------------------------------------------------------------------------- +# The hook rule: a moment only some backends run is a moment the flow says it needs. +# --------------------------------------------------------------------------------------- + + +def _hooks(read: _Read, declared: frozenset[str]) -> Iterator[Finding]: + """Every moment one file hangs a hook on, held to the moments the flow declared. + + Args: + read: The file. + declared: Every moment named in an annotation anywhere in the flow, which is how a + place says what the agent filling it has to run. + + Yields: + An `unsaid-moment` warning per hook hung on a moment only some backends reach that + no place declares: the run finds out from `Unhooked`, mid-flow, where a declaration + would have refused the agent before its first turn. + """ + everywhere: frozenset[str] | None = None + for node in ast.walk(read.tree): + if not ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "on" + and node.args + ): + continue + moment = node.args[0] + if not ( + isinstance(moment, ast.Attribute) + and isinstance(moment.value, ast.Name) + and moment.value.id in read.moment_alias + ): + continue + if everywhere is None: + # Read off the live enum rather than copied out of it, so that a moment + # humanize adds is a moment this rule already knows. Fetched here rather + # than imported with the module: the vocabulary lives beside the drivers. + from hmz.agents import EVERYWHERE + + everywhere = frozenset(one.name for one in EVERYWHERE) + if moment.attr in everywhere or moment.attr in declared: + continue + yield Finding( + "unsaid-moment", + "warning", + read.where, + node.lineno, + f"a hook is hung on Moment.{moment.attr}, which only some backends run, and " + "no place declares it -- write Annotated[Agent, Moment." + f"{moment.attr}] where the place is declared, so an agent that cannot run " + "it is refused before its first turn rather than hours in", + ) + + +# --------------------------------------------------------------------------------------- +# The function rules: what is asked of what the flow drives, and how its loops end. +# --------------------------------------------------------------------------------------- + + +class _Crew(NamedTuple): + """A tuple of agents as one function holds it: the places, or only the count. + + Attributes: + fields: One (name, kinds) pair per place for a NamedTuple of them, or None for a + plain tuple, which named nothing. Kinds of None is a place whose annotation this + file cannot read, which is tracked and asked nothing. + kinds: The kind of each element by position, for a plain tuple that said them. + """ + + fields: tuple[tuple[str, frozenset[str] | None], ...] | None + kinds: tuple[frozenset[str] | None, ...] = () + + def held(self) -> frozenset[str] | None: + """What one element of this crew is, where every place is the same thing.""" + each = ( + [kinds for _, kinds in self.fields] + if self.fields is not None + else list(self.kinds) + ) + if not each or any(not one for one in each): + return None + return frozenset(kind for one in each if one for kind in one) + + +class _Answer(NamedTuple): + """One name holding what a turn answered, and how the turn was taken.""" + + shaped: bool + suppressed: bool + line: int + + +@dataclass +class _Asks: + """What each kind of tracked thing may be asked, read once per checking.""" + + _surfaces: dict[str, frozenset[str]] = field( + default_factory=dict[str, frozenset[str]] + ) + + def allowed(self, kinds: frozenset[str]) -> frozenset[str]: + """Every name something of these kinds answers to.""" + held: frozenset[str] = frozenset() + for kind in kinds: + if kind not in self._surfaces: + self._surfaces[kind] = surface(_KINDS[kind]) + held |= self._surfaces[kind] + return held + + +@dataclass +class _Scope: + """One function being read: what is bound to what, and what was found so far.""" + + read: _Read + asks: _Asks + bindings: dict[str, frozenset[str] | _Crew] = field( + default_factory=dict[str, "frozenset[str] | _Crew"] + ) + answers: dict[str, _Answer] = field(default_factory=dict[str, "_Answer"]) + guarded: set[str] = field(default_factory=set[str]) + #: Attribute reads off a shaped, suppressed answer: (name, line) apiece. + reads: list[tuple[str, int]] = field(default_factory=list[tuple[str, int]]) + #: Whether the function holds a bound of its own -- a spent() call, a range(), an + #: ordering comparison against a number -- which is what excuses its loops. + bounded: bool = False + findings: list[Finding] = field(default_factory=list[Finding]) + + def forgot(self, name: str) -> None: + """Stops tracking one name, which is what any doubtful binding does to it.""" + self.bindings.pop(name, None) + self.answers.pop(name, None) + + +def _functions(read: _Read, asks: _Asks) -> Iterator[Finding]: + """Reads every function in one file for what it asks and how its loops end. + + Args: + read: The file. + asks: The interface surfaces, shared across the files of one checking. + + Yields: + The findings, function by function. + """ + for node in read.tree.body: + yield from _defined(node, read, asks, {}) + + +def _defined( + node: ast.stmt, + read: _Read, + asks: _Asks, + inherited: dict[str, frozenset[str] | _Crew], +) -> Iterator[Finding]: + """One top-level statement, read for the functions in it.""" + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + yield from _function(node, read, asks, inherited) + elif isinstance(node, ast.ClassDef): + for held in node.body: + yield from _defined(held, read, asks, inherited) + elif isinstance(node, ast.If): + for held in [*node.body, *node.orelse]: + yield from _defined(held, read, asks, inherited) + + +def _function( + node: ast.FunctionDef | ast.AsyncFunctionDef, + read: _Read, + asks: _Asks, + inherited: dict[str, frozenset[str] | _Crew], +) -> Iterator[Finding]: + """One function: bindings followed in order, then its loops read against them. + + Args: + node: The function. + read: Its file. + asks: The interface surfaces. + inherited: What the enclosing function had bound, which a closure reads. + """ + scope = _Scope(read, asks, bindings=dict(inherited)) + args = node.args + params = [*args.posonlyargs, *args.args, *args.kwonlyargs] + for param in params: + scope.forgot(param.arg) + kind = _annotated(param.annotation, read) + crew = _crewed(param.annotation, read) if not kind else None + if kind: + scope.bindings[param.arg] = kind + elif crew is not None: + scope.bindings[param.arg] = crew + for one in (args.vararg, args.kwarg): + if one is not None: + scope.forgot(one.arg) + _statements(node.body, scope, read, asks) + yield from scope.findings + for name, line in scope.reads: + if name not in scope.guarded: + yield Finding( + "unguarded-answer", + "warning", + read.where, + line, + f"{name} may be None here -- a suppressed turn held to a shape answers " + "with nothing when it fails, and a field read off nothing ends the run " + "where a guard would have taken the turn again", + ) + if not _yields(node): + yield from _loops(node, scope) + + +def _annotated(annotation: ast.expr | None, read: _Read) -> frozenset[str] | None: + """What kinds of driven thing one annotation says a name is, or None for no answer.""" + said = _unquoted(annotation) + if said is None: + return None + if isinstance(said, ast.Subscript) and _root(said.value) == "Annotated": + first = next(iter(_elements(said.slice)), None) + return _annotated(first, read) + if isinstance(said, ast.Subscript) and _root(said.value) == "Optional": + first = next(iter(_elements(said.slice)), None) + return _annotated(first, read) + if isinstance(said, ast.BinOp) and isinstance(said.op, ast.BitOr): + left = _annotated(said.left, read) + right = _annotated(said.right, read) + if left is None and right is None: + return None + # `Agent | None` is an agent to be guarded; `Agent | Session` is either, and is + # asked only what both answer to being too strict -- so it is asked the union. + return (left or frozenset()) | (right or frozenset()) + if isinstance(said, ast.Constant) and said.value is None: + return frozenset() + if isinstance(said, ast.Name) and said.id in read.proto: + return frozenset({read.proto[said.id]}) + return None + + +def _crewed(annotation: ast.expr | None, read: _Read) -> _Crew | None: + """The tuple of agents one annotation declares, where this file can read it. + + A crew it is not sure of is no crew at all: a NamedTuple with one place this file + cannot read as an interface is somebody's data rather than the agents, and tracking + it would find askings that are nobody's business here. + """ + said = _unquoted(annotation) + if said is None: + return None + if isinstance(said, ast.Name) and said.id in read.crews: + fields = [ + (node.target.id, _annotated(node.annotation, read)) + for node in read.crews[said.id].body + if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) + ] + if fields and all(kinds for _, kinds in fields): + return _Crew(tuple(fields)) + return None + if isinstance(said, ast.Subscript) and _root(said.value) == "tuple": + kinds = tuple( + _annotated(one, read) + for one in _elements(said.slice) + if not (isinstance(one, ast.Constant) and one.value is Ellipsis) + ) + if kinds and all(kinds): + return _Crew(None, kinds) + return None + + +def _unquoted(annotation: ast.expr | None) -> ast.expr | None: + """One annotation with any quoting read through, since a string is still the words.""" + if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str): + try: + return ast.parse(annotation.value, mode="eval").body + except (SyntaxError, ValueError): + return None + return annotation + + +def _elements(slice_: ast.expr) -> tuple[ast.expr, ...]: + """The elements of one subscript, one or many.""" + return tuple(slice_.elts) if isinstance(slice_, ast.Tuple) else (slice_,) + + +def _names_in(annotation: ast.expr) -> set[str]: + """Every plain name one annotation mentions, quoting and all.""" + said = _unquoted(annotation) + if said is None: + return set() + return {node.id for node in ast.walk(said) if isinstance(node, ast.Name)} + + +def _statements(body: list[ast.stmt], scope: _Scope, read: _Read, asks: _Asks) -> None: + """Walks statements in order, checking what they ask and following what they bind.""" + for node in body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + scope.findings.extend(_function(node, read, asks, dict(scope.bindings))) + scope.forgot(node.name) + elif isinstance(node, ast.ClassDef): + for held in node.body: + if isinstance(held, (ast.FunctionDef, ast.AsyncFunctionDef)): + scope.findings.extend(_function(held, read, asks, {})) + scope.forgot(node.name) + elif isinstance(node, ast.Assign): + _expression(node.value, scope) + _assigned(node.targets, node.value, scope) + elif isinstance(node, ast.AnnAssign): + if node.value is not None: + _expression(node.value, scope) + if isinstance(node.target, ast.Name): + scope.forgot(node.target.id) + kind = _annotated(node.annotation, scope.read) + if kind: + scope.bindings[node.target.id] = kind + elif node.value is not None: + _assigned([node.target], node.value, scope) + else: + _expression(node.target, scope) + elif isinstance(node, ast.AugAssign): + _expression(node.value, scope) + if isinstance(node.target, ast.Name): + scope.forgot(node.target.id) + elif isinstance(node, (ast.For, ast.AsyncFor)): + _expression(node.iter, scope) + _bound_target(node.target, _element_of(node.iter, scope), scope) + _statements(node.body, scope, read, asks) + _statements(node.orelse, scope, read, asks) + elif isinstance(node, (ast.While, ast.If)): + _guards(node.test, scope) + _expression(node.test, scope) + _statements(node.body, scope, read, asks) + _statements(node.orelse, scope, read, asks) + elif isinstance(node, (ast.With, ast.AsyncWith)): + for item in node.items: + _expression(item.context_expr, scope) + if item.optional_vars is not None: + _bound_target(item.optional_vars, None, scope) + _statements(node.body, scope, read, asks) + elif isinstance(node, ast.Try): + _statements(node.body, scope, read, asks) + for handler in node.handlers: + if handler.name: + scope.forgot(handler.name) + _statements(handler.body, scope, read, asks) + _statements(node.orelse, scope, read, asks) + _statements(node.finalbody, scope, read, asks) + elif isinstance(node, ast.Assert): + _guards(node.test, scope) + _expression(node.test, scope) + elif isinstance(node, ast.Delete): + for target in node.targets: + if isinstance(target, ast.Name): + scope.forgot(target.id) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + for alias in node.names: + scope.forgot((alias.asname or alias.name).split(".")[0]) + elif isinstance(node, (ast.Return, ast.Expr)): + if node.value is not None: + _expression(node.value, scope) + elif isinstance(node, ast.Raise): + for held in (node.exc, node.cause): + if held is not None: + _expression(held, scope) + elif isinstance(node, ast.Match): + _expression(node.subject, scope) + for case in node.cases: + for name in _captured(case.pattern): + scope.forgot(name) + if case.guard is not None: + _expression(case.guard, scope) + _statements(case.body, scope, read, asks) + + +def _captured(pattern: ast.pattern) -> set[str]: + """Every name one match pattern binds, all of which stop being tracked.""" + said: set[str] = set() + for node in ast.walk(pattern): + if isinstance(node, (ast.MatchAs, ast.MatchStar)) and node.name: + said.add(node.name) + elif isinstance(node, ast.MatchMapping) and node.rest: + said.add(node.rest) + return said + + +def _assigned(targets: list[ast.expr], value: ast.expr, scope: _Scope) -> None: + """Follows one assignment: what the targets are now bound to, if anything tracked.""" + held = _valued(value, scope) + for target in targets: + if isinstance(target, ast.Name): + scope.forgot(target.id) + if isinstance(held, _Answer): + scope.answers[target.id] = held + elif held is not None: + scope.bindings[target.id] = held + elif isinstance(target, (ast.Tuple, ast.List)): + _unpacked(target, value, scope) + else: + _expression(target, scope) + + +def _unpacked(target: ast.Tuple | ast.List, value: ast.expr, scope: _Scope) -> None: + """Follows a tuple unpacking, which is how `(agent,) = agents` hands a place over.""" + crew = scope.bindings.get(value.id) if isinstance(value, ast.Name) else None + each = crew.held() if isinstance(crew, _Crew) else None + kinds: list[frozenset[str] | None] | None = None + if isinstance(crew, _Crew): + held = ( + [one for _, one in crew.fields] + if crew.fields is not None + else list(crew.kinds) + ) + kinds = held if len(held) == len(target.elts) else None + for at, one in enumerate(target.elts): + if isinstance(one, ast.Name): + scope.forgot(one.id) + bound = kinds[at] if kinds is not None else each + if isinstance(crew, _Crew) and bound: + scope.bindings[one.id] = bound + elif isinstance(one, (ast.Tuple, ast.List)): + _unpacked(one, value, scope) + elif isinstance(one, ast.Starred) and isinstance(one.value, ast.Name): + scope.forgot(one.value.id) + + +def _bound_target(target: ast.expr, kind: frozenset[str] | None, scope: _Scope) -> None: + """Binds a loop or with target: to the element kind where there is one, else to doubt.""" + if isinstance(target, ast.Name): + scope.forgot(target.id) + if kind: + scope.bindings[target.id] = kind + elif isinstance(target, (ast.Tuple, ast.List)): + for one in target.elts: + _bound_target(one, None, scope) + + +def _element_of(iterable: ast.expr, scope: _Scope) -> frozenset[str] | None: + """What iterating one expression yields, where it is a tracked crew.""" + if isinstance(iterable, ast.Name): + held = scope.bindings.get(iterable.id) + if isinstance(held, _Crew): + return held.held() + return None + + +def _valued(value: ast.expr, scope: _Scope) -> frozenset[str] | _Crew | _Answer | None: + """What one expression is, as far as the bindings can say. + + Args: + value: The expression on the right of an assignment. + scope: The function so far. + + Returns: + A kind for something driven, a crew for a tuple of them, an answer for what a turn + of one said, and None for anything this reading does not follow -- which stops the + target being tracked rather than mistracking it. + """ + if isinstance(value, ast.Name): + return scope.bindings.get(value.id) + if isinstance(value, ast.Await): + return _valued(value.value, scope) + if isinstance(value, ast.Attribute): + held = ( + scope.bindings.get(value.value.id) + if isinstance(value.value, ast.Name) + else None + ) + if isinstance(held, _Crew) and held.fields is not None: + return next( + (kinds for name, kinds in held.fields if name == value.attr), None + ) + return None + if isinstance(value, ast.Subscript): + held = ( + scope.bindings.get(value.value.id) + if isinstance(value.value, ast.Name) + else None + ) + return held.held() if isinstance(held, _Crew) else None + if isinstance(value, ast.Call): + return _called(value, scope) + return None + + +def _called(value: ast.Call, scope: _Scope) -> frozenset[str] | _Crew | _Answer | None: + """What calling one thing answers with, where what is called is tracked.""" + func = value.func + opened = isinstance(func, ast.Attribute) and func.attr in {"new", "clone"} + target = func.value if isinstance(func, ast.Attribute) and opened else func + held = _valued(target, scope) if opened else None + if isinstance(func, ast.Attribute) and func.attr == "new": + if isinstance(held, frozenset) and held: + return frozenset({"session"}) + return None + if isinstance(func, ast.Attribute) and func.attr == "clone": + return held if isinstance(held, frozenset) else None + spoken = _valued(func, scope) + if isinstance(spoken, frozenset) and spoken: + # Calling an agent or a session is a turn, and what it answers is an answer. + return _Answer( + shaped=any(one.arg == "schema" for one in value.keywords), + suppressed=any( + one.arg == "suppress" + and isinstance(one.value, ast.Constant) + and one.value.value is True + for one in value.keywords + ), + line=value.lineno, + ) + if isinstance(func, ast.Attribute) and func.attr in {"aturn", "pursue", "apursue"}: + asked = _valued(func.value, scope) + if isinstance(asked, frozenset) and asked: + return _Answer( + shaped=any(one.arg == "schema" for one in value.keywords), + suppressed=any( + one.arg == "suppress" + and isinstance(one.value, ast.Constant) + and one.value.value is True + for one in value.keywords + ), + line=value.lineno, + ) + return None + + +#: The comparisons that read as a bound: a count against something, ordered. +_ORDERED = (ast.Lt, ast.LtE, ast.Gt, ast.GtE) + + +def _expression(node: ast.expr, scope: _Scope) -> None: + """Checks one expression tree against the bindings, and notes what it holds. + + What is checked is every attribute asked of a tracked name; what is noted is every + guard on an answer, and whether the function holds a bound of its own. + """ + if isinstance(node, ast.Attribute): + _asked(node, scope) + _expression(node.value, scope) + return + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Attribute) and node.func.attr == "spent": + scope.bounded = True + if isinstance(node.func, ast.Name) and node.func.id == "range": + scope.bounded = True + for one in [node.func, *node.args]: + _expression(one, scope) + for kw in node.keywords: + _expression(kw.value, scope) + return + if isinstance(node, ast.Compare): + if any(isinstance(op, _ORDERED) for op in node.ops) and any( + isinstance(side, ast.Constant) + and isinstance(side.value, (int, float)) + and not isinstance(side.value, bool) + for side in [node.left, *node.comparators] + ): + scope.bounded = True + _guards(node, scope) + for one in [node.left, *node.comparators]: + _expression(one, scope) + return + if isinstance(node, ast.BoolOp): + _guards(node, scope) + for one in node.values: + _expression(one, scope) + return + if isinstance(node, ast.IfExp): + _guards(node.test, scope) + for one in (node.test, node.body, node.orelse): + _expression(one, scope) + return + if isinstance(node, ast.NamedExpr): + _expression(node.value, scope) + scope.forgot(node.target.id) + held = _valued(node.value, scope) + if isinstance(held, _Answer): + scope.answers[node.target.id] = held + elif held is not None: + scope.bindings[node.target.id] = held + return + if isinstance(node, ast.Lambda): + return # its own scope, and nothing in it runs here + if isinstance(node, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)): + _comprehended(node, scope) + return + for one in ast.iter_child_nodes(node): + if isinstance(one, ast.expr): + _expression(one, scope) + + +def _comprehended( + node: ast.ListComp | ast.SetComp | ast.GeneratorExp | ast.DictComp, + scope: _Scope, +) -> None: + """One comprehension, read with its own targets bound for as long as it lasts.""" + before = dict(scope.bindings) + for gen in node.generators: + _expression(gen.iter, scope) + _bound_target(gen.target, _element_of(gen.iter, scope), scope) + for test in gen.ifs: + _guards(test, scope) + _expression(test, scope) + if isinstance(node, ast.DictComp): + _expression(node.key, scope) + _expression(node.value, scope) + else: + _expression(node.elt, scope) + scope.bindings = before + + +def _guards(test: ast.expr, scope: _Scope) -> None: + """Notes every answer one test stands guard over. + + A guard is the answer read as a truth -- `if worked:` -- or compared against None, + either way round. Noted wherever it appears rather than matched to a branch: what the + warning is for is the answer nobody tested at all. + """ + if isinstance(test, ast.Name): + scope.guarded.add(test.id) + elif isinstance(test, ast.UnaryOp) and isinstance(test.operand, ast.Name): + scope.guarded.add(test.operand.id) + elif isinstance(test, ast.BoolOp): + for one in test.values: + _guards(one, scope) + elif isinstance(test, ast.Compare): + sides = [test.left, *test.comparators] + against_none = any( + isinstance(side, ast.Constant) and side.value is None for side in sides + ) + if against_none: + for side in sides: + if isinstance(side, ast.Name): + scope.guarded.add(side.id) + + +def _asked(node: ast.Attribute, scope: _Scope) -> None: + """One attribute asked of a name, checked where the name is tracked.""" + if not isinstance(node.value, ast.Name) or node.attr.startswith("_"): + return + name = node.value.id + answer = scope.answers.get(name) + if answer is not None and answer.shaped and answer.suppressed: + scope.reads.append((name, node.lineno)) + return + held = scope.bindings.get(name) + if held is None: + return + if isinstance(held, _Crew): + places: frozenset[str] = ( + frozenset(field for field, _ in held.fields) + if held.fields is not None + else frozenset() + ) + if node.attr in places | _OF_A_TUPLE: + return + which = ( + ", ".join(field for field, _ in held.fields) + if held.fields + else "how many there are, and nothing else" + ) + scope.findings.append( + Finding( + "unknown-ask", + "error", + scope.read.where, + node.lineno, + f"the agents have no place called {node.attr!r} -- the flow declares " + f"{which}, and a place it does not declare is not one it was handed", + ) + ) + return + if node.attr in scope.asks.allowed(held): + return + what = " or ".join(f"hmz.flows.{kind.capitalize()}" for kind in sorted(held)) + scope.findings.append( + Finding( + "unknown-ask", + "error", + scope.read.where, + node.lineno, + f"nothing here answers to {node.attr!r} -- what a flow may ask is written " + f"on {what}, and a name that is not there fails at the first turn", + ) + ) + + +# --------------------------------------------------------------------------------------- +# The loop rules: a loop is legal when something inside it can end it. +# --------------------------------------------------------------------------------------- + + +def _yields(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + """Whether one function is a generator, whose loops end where their consumer stops.""" + waiting: list[ast.AST] = list(ast.iter_child_nodes(node)) + while waiting: + held = waiting.pop() + if isinstance(held, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + continue + if isinstance(held, (ast.Yield, ast.YieldFrom)): + return True + waiting.extend(ast.iter_child_nodes(held)) + return False + + +class _Exit(NamedTuple): + """One way out of a loop, and the conditions standing between the loop and it.""" + + line: int + conditions: tuple[ast.expr, ...] + + +def _loops( + node: ast.FunctionDef | ast.AsyncFunctionDef, scope: _Scope +) -> Iterator[Finding]: + """Every constant-true loop in one function, read for how it ends. + + Args: + node: The function, with its bindings already followed. + scope: What following them collected. + + Yields: + A `dead-loop` error for one nothing inside can end, a `sleeping-loop` error for one + that only sleeps -- alive from the outside and doing nothing -- and an + `unbounded-loop` warning for one whose every way out waits for an agent to say so, + in a function with no bound of its own. + """ + for loop in _whiles(node.body): + if not (isinstance(loop.test, ast.Constant) and loop.test.value): + continue + exits = _exits(loop.body, ()) + if not exits: + if _sleeps(loop.body): + code = "sleeping-loop" + said = ( + "this loop only sleeps -- from outside it looks alive, and each " + "round does nothing; a loop earns its keep by doing something " + "that can end it" + ) + else: + code = "dead-loop" + said = ( + "this loop cannot end -- no break, no return, no raise inside " + "it; a loop is legal when something inside it can end it" + ) + yield Finding(code, "error", scope.read.where, loop.lineno, said) + continue + if scope.bounded: + continue + shaped = {name for name, answer in scope.answers.items() if answer.shaped} + if all(_by_verdict(one, shaped) for one in exits): + yield Finding( + "unbounded-loop", + "warning", + scope.read.where, + loop.lineno, + "every way out of this loop waits for an agent to say so, and an agent " + "may never say it -- give the loop a bound of its own: a budget read " + "off spent(), a cap on the rounds, a range", + ) + + +def _whiles(body: list[ast.stmt]) -> Iterator[ast.While]: + """Every while loop in one function's own body, nested functions left to themselves.""" + for node in body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + if isinstance(node, ast.While): + yield node + for held in _blocks(node): + yield from _whiles(held) + + +def _blocks(node: ast.stmt) -> Iterator[list[ast.stmt]]: + """The statement blocks one statement holds, whichever shape it is.""" + for name in ("body", "orelse", "finalbody"): + held = getattr(node, name, None) + if isinstance(held, list) and held and isinstance(held[0], ast.stmt): + yield held + for handler in getattr(node, "handlers", []): + yield handler.body + for case in getattr(node, "cases", []): + yield case.body + + +def _exits(body: list[ast.stmt], conditions: tuple[ast.expr, ...]) -> list[_Exit]: + """Every way out of a loop with this body, each with the conditions guarding it. + + A `break` of the loop's own, a `return`, a `raise`: anything that ends the loop or the + function around it. A `break` inside a nested loop ends that loop instead, and is not + one; a `return` inside one still ends the function, and is. + """ + found: list[_Exit] = [] + for node in body: + if isinstance(node, (ast.Break, ast.Return, ast.Raise)): + found.append(_Exit(node.lineno, conditions)) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + elif isinstance(node, ast.If): + found.extend(_exits(node.body, (*conditions, node.test))) + found.extend(_exits(node.orelse, (*conditions, node.test))) + elif isinstance(node, (ast.While, ast.For, ast.AsyncFor)): + # A break in there is that loop's; a return or a raise is still a way out. + for held in _blocks(node): + found.extend( + one + for one in _exits(held, conditions) + if not _breaks_at(node, one.line) + ) + else: + for held in _blocks(node): + found.extend(_exits(held, conditions)) + return found + + +def _breaks_at(loop: ast.stmt, line: int) -> bool: + """Whether the exit at this line is a break belonging to this nested loop.""" + return any( + isinstance(node, ast.Break) and node.lineno == line + for node in ast.walk(loop) + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + ) + + +def _by_verdict(exit_: _Exit, shaped: set[str]) -> bool: + """Whether one way out waits on a field of what an agent answered. + + The `review.done` shape: a condition reading an attribute off an answer the turn was + held to. A turn merely having landed -- `if said:` -- is not one, since a loop that + takes a failed turn again is bounded by the turns landing, not by what they say. + """ + for condition in exit_.conditions: + for node in ast.walk(condition): + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in shaped + and not node.attr.startswith(("_", "model_")) + ): + return True + return False + + +def _sleeps(body: list[ast.stmt]) -> bool: + """Whether a loop body does nothing but wait: sleeps, passes, and constants.""" + for node in body: + if isinstance(node, (ast.Pass, ast.Continue)): + continue + if isinstance(node, ast.Expr): + value = node.value + if isinstance(value, ast.Constant): + continue + if isinstance(value, ast.Call) and ( + (isinstance(value.func, ast.Name) and value.func.id == "sleep") + or ( + isinstance(value.func, ast.Attribute) and value.func.attr == "sleep" + ) + ): + continue + return False + if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant): + continue + return False + return True diff --git a/tests/test_checking.py b/tests/test_checking.py new file mode 100644 index 0000000..d1e78ae --- /dev/null +++ b/tests/test_checking.py @@ -0,0 +1,842 @@ +"""The static read of a flow's legality, held to finding what it claims and nothing else. + +Two halves. A fixture or two per rule -- a flow that trips it, and a neighbour standing just +on the legal side -- so that every rule is shown to fire and shown to know where the edge is. +And a sweep over every flow humanize ships and every flow the official flowverse holds: the +rules were written against real flows, and the sweep is the alarm that goes off the day one +of them starts reading a good flow as a bad one. +""" + +from __future__ import annotations + +import textwrap +from typing import TYPE_CHECKING + +import pytest + +import hmz.flows +from hmz.flows import BUILTIN_AT, ENTRY, Agent, Driven, Person, Session, entry +from hmz.flows.checking import checked, offered, surface +from tests.stubs import written + +if TYPE_CHECKING: + from pathlib import Path + +#: What each code is, so that a rule quietly changing its severity is a failing test. +SEVERITY = { + "unread": "error", + "not-a-flow": "error", + "unsized-agents": "error", + "unread-annotation": "error", + "foreign-import": "error", + "unknown-name": "error", + "unknown-ask": "error", + "dead-loop": "error", + "sleeping-loop": "error", + "stateless-resume": "error", + "unbounded-loop": "warning", + "unguarded-answer": "warning", + "unsaid-moment": "warning", + "loose-config": "warning", + "unsaid-field": "warning", + "unsaid-flow": "warning", + "state-kept": "warning", + "twice-named": "warning", +} + +#: What a flow standing just on the legal side of a rule reads as: nothing at all. +CLEAN: set[str] = set() + +#: The one line every fixture flow says about itself, so `unsaid-flow` stays out of the way +#: of every rule but its own. +DOC = '"""A flow written for one rule of the checker."""\n' + +CASES = [ + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + (agent,) = agents + while True: + agent(task, suppress=True) +""", + {"dead-loop"}, + id="dead-loop", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + (agent,) = agents + while True: + worked = agent(task, suppress=True) + if not worked: + break +""", + CLEAN, + id="dead-loop-edge-a-break-inside", + ), + pytest.param( + DOC + + """ +import time + +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + while True: + time.sleep(5) +""", + {"sleeping-loop"}, + id="sleeping-loop", + ), + pytest.param( + DOC + + """ +import time + +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + while True: + time.sleep(5) + break +""", + CLEAN, + id="sleeping-loop-edge-it-can-end", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + (agent,) = agents + agent.launch(task) +""", + {"unknown-ask"}, + id="unknown-ask", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + (agent,) = agents + print(agent.spent().output) + session = agent.new() + session(task, suppress=True) + session.close() +""", + CLEAN, + id="unknown-ask-edge-the-interface", + ), + pytest.param( + DOC + + """ +from typing import NamedTuple + +from hmz.flows import Agent, Person, flow + +class Crew(NamedTuple): + actor: Agent + human: Person + +@flow +def run(agents: Crew, task: str) -> None: + agents.reviewer(task) +""", + {"unknown-ask"}, + id="unknown-ask-a-place-not-declared", + ), + pytest.param( + DOC + + """ +from typing import NamedTuple + +from hmz.flows import Agent, Person, flow + +class Crew(NamedTuple): + actor: Agent + human: Person + +@flow +def run(agents: Crew, task: str) -> None: + agents.actor(task, suppress=True) + agents.human.board.put("doing", task) +""", + CLEAN, + id="unknown-ask-edge-the-places-declared", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + session = agents[0].new() + session.rewind() +""", + {"unknown-ask"}, + id="unknown-ask-of-a-session", + ), + pytest.param( + DOC + + """ +from hmz.agents import Moment + +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + agents[0](task) +""", + {"foreign-import"}, + id="foreign-import", + ), + pytest.param( + DOC + + """ +import hmz.backends + +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + agents[0](task) +""", + {"foreign-import"}, + id="foreign-import-a-module", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, Moment, Usage, backends, flow, home + +@flow +def run(agents: tuple[Agent], task: str) -> None: + agents[0](task) +""", + CLEAN, + id="foreign-import-edge-the-one-import", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow, teleport + +@flow +def run(agents: tuple[Agent], task: str) -> None: + agents[0](task) +""", + {"unknown-name"}, + id="unknown-name", + ), + pytest.param( + DOC + + """ +from hmz.flows import flow + +@flow +def run(agents, task): + agents[0](task) +""", + {"unsized-agents"}, + id="unsized-agents-nothing-said", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple, task: str) -> None: + agents[0](task) +""", + {"unsized-agents"}, + id="unsized-agents-a-bare-tuple", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent, ...], task: str) -> None: + agents[0](task) +""", + {"unsized-agents"}, + id="unsized-agents-any-number", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent, Agent], task: str) -> None: + agents[0](task) + agents[1](task) +""", + CLEAN, + id="unsized-agents-edge-a-fixed-length", + ), + pytest.param( + DOC + + """ +from typing import TYPE_CHECKING + +from hmz.flows import flow + +if TYPE_CHECKING: + from hmz.flows import Agent + +@flow +def run(agents: "tuple[Agent]", task: str) -> None: + agents[0](task) +""", + {"unread-annotation"}, + id="unread-annotation", + ), + pytest.param( + DOC + + """ +from typing import TYPE_CHECKING + +from hmz.flows import Agent, flow + +if TYPE_CHECKING: + from hmz.flows import Agent + +@flow +def run(agents: "tuple[Agent]", task: str) -> None: + agents[0](task) +""", + CLEAN, + id="unread-annotation-edge-also-at-runtime", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow + +@flow(resumable=True) +def run(agents: tuple[Agent], task: str) -> None: + agents[0](task) +""", + {"stateless-resume"}, + id="stateless-resume", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + +class Config(BaseModel): + model_config = {"extra": "forbid"} + + budget: float = Field(default=1.0, description="the bound") + +@flow(resumable=True) +def run(agents: tuple[Agent], task: str, config: Config | None = None) -> None: + agents[0](task) +""", + {"stateless-resume"}, + id="stateless-resume-a-config-in-the-way", + ), + pytest.param( + DOC + + """ +from typing import Any + +from hmz.flows import Agent, flow + +@flow(resumable=True) +def run(agents: tuple[Agent], task: str, state: dict[str, Any]) -> None: + agents[0](task) +""", + CLEAN, + id="stateless-resume-edge-the-state-third", + ), + pytest.param( + DOC + + """ +import time + +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + +class Review(BaseModel): + model_config = {"extra": "forbid"} + + done: bool = Field(description="whether it is over") + +@flow +def run(agents: tuple[Agent, Agent], task: str) -> None: + working = agents[0].new() + prompt = task + while True: + worked = working(prompt, suppress=True) + if worked: + review = agents[1](task, suppress=True, schema=Review) + if review is not None and review.done: + return + time.sleep(5) +""", + {"unbounded-loop"}, + id="unbounded-loop", + ), + pytest.param( + DOC + + """ +import time + +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + +class Review(BaseModel): + model_config = {"extra": "forbid"} + + done: bool = Field(description="whether it is over") + +@flow +def run(agents: tuple[Agent, Agent], task: str) -> None: + working = agents[0].new() + while True: + working(task, suppress=True) + review = agents[1](task, suppress=True, schema=Review) + if review is not None and review.done: + return + if agents[0].spent().output > 1_000_000: + return + time.sleep(5) +""", + CLEAN, + id="unbounded-loop-edge-a-budget-bound", + ), + pytest.param( + DOC + + """ +import time + +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + (agent,) = agents + while True: + said = agent(task, suppress=True) + if said: + return + time.sleep(5) +""", + CLEAN, + id="unbounded-loop-edge-a-turn-merely-landing", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + +class Review(BaseModel): + model_config = {"extra": "forbid"} + + done: bool = Field(description="whether it is over") + +@flow +def run(agents: tuple[Agent], task: str) -> None: + review = agents[0](task, suppress=True, schema=Review) + print(review.done) +""", + {"unguarded-answer"}, + id="unguarded-answer", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + +class Review(BaseModel): + model_config = {"extra": "forbid"} + + done: bool = Field(description="whether it is over") + +@flow +def run(agents: tuple[Agent], task: str) -> None: + review = agents[0](task, suppress=True, schema=Review) + if review is not None and review.done: + print("done") +""", + CLEAN, + id="unguarded-answer-edge-guarded", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, Moment, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + agents[0].hooks.on(Moment.PERMISSION_REQUEST, print) +""", + {"unsaid-moment"}, + id="unsaid-moment", + ), + pytest.param( + DOC + + """ +from typing import Annotated, NamedTuple + +from hmz.flows import Agent, Moment, flow + +class Crew(NamedTuple): + builder: Annotated[Agent, Moment.PERMISSION_REQUEST] + +@flow +def run(agents: Crew, task: str) -> None: + agents.builder.hooks.on(Moment.PERMISSION_REQUEST, print) +""", + CLEAN, + id="unsaid-moment-edge-the-place-declares-it", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, Moment, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + agents[0].hooks.on(Moment.STOP, print) +""", + CLEAN, + id="unsaid-moment-edge-a-moment-every-backend-runs", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + +class Config(BaseModel): + budget: float = Field(default=1.0, description="the bound") + +@flow +def run(agents: tuple[Agent], task: str, config: Config | None = None) -> None: + agents[0](task) +""", + {"loose-config"}, + id="loose-config", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + +class Config(BaseModel): + model_config = {"frozen": True} + + budget: float = Field(default=1.0, description="the bound") + +@flow +def run(agents: tuple[Agent], task: str, config: Config | None = None) -> None: + agents[0](task) +""", + CLEAN, + id="loose-config-edge-frozen", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow +from pydantic import BaseModel + +class Config(BaseModel): + model_config = {"extra": "forbid"} + + budget: float + +@flow +def run(agents: tuple[Agent], task: str, config: Config | None = None) -> None: + agents[0](task) +""", + {"unsaid-field"}, + id="unsaid-field", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow +from pydantic import BaseModel + +class Answer(BaseModel): + said: str + +@flow +def run(agents: tuple[Agent], task: str) -> None: + agents[0](task, suppress=True, schema=Answer) +""", + CLEAN, + id="unsaid-field-edge-a-schema-is-not-a-config", + ), + pytest.param( + """ +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + agents[0](task) +""", + {"unsaid-flow"}, + id="unsaid-flow", + ), + pytest.param( + DOC + + """ +from typing import Any + +from hmz.flows import Agent, flow + +@flow(resumable=True) +def run(agents: tuple[Agent], task: str, state: dict[str, Any]) -> None: + kept = state + kept["rounds"] = kept.get("rounds", 0) + 1 + agents[0](task) +""", + {"state-kept"}, + id="state-kept", + ), + pytest.param( + DOC + + """ +from typing import Any + +from hmz.flows import Agent, flow + +@flow(resumable=True) +def run(agents: tuple[Agent], task: str, state: dict[str, Any]) -> None: + kept = state + kept["rounds"] = kept.get("rounds", 0) + 1 + agents[0](task) + kept.clear() +""", + CLEAN, + id="state-kept-edge-cleared", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow + +@flow(name="draft") +def one(agents: tuple[Agent], task: str) -> None: + agents[0](task) + +@flow(name="draft") +def two(agents: tuple[Agent], task: str) -> None: + agents[0](task) +""", + {"twice-named"}, + id="twice-named", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow + +@flow(name="draft") +def one(agents: tuple[Agent], task: str) -> None: + agents[0](task) + +@flow(name="check") +def two(agents: tuple[Agent], task: str) -> None: + agents[0](task) +""", + CLEAN, + id="twice-named-edge-two-names", + ), + pytest.param( + DOC + + """ +def run(agents, task): + return None +""", + {"not-a-flow"}, + id="not-a-flow", + ), + pytest.param( + "def run(:\n", + {"unread"}, + id="unread", + ), +] + + +@pytest.mark.parametrize(("source", "expected"), CASES) +def test_each_rule_fires_and_knows_the_edge( + tmp_path: Path, source: str, expected: set[str] +) -> None: + at = written(tmp_path, "one", textwrap.dedent(source)) + found = checked(at) + assert {one.code for one in found} == expected, found + for one in found: + assert one.severity == SEVERITY[one.code] + assert one.line >= 0 + + +def test_a_flow_that_is_not_there_is_not_a_flow(tmp_path: Path) -> None: + found = checked(tmp_path / "nowhere") + assert [one.code for one in found] == ["not-a-flow"] + + +def test_a_single_file_flow_is_read_as_one(tmp_path: Path) -> None: + at = tmp_path / "alone.py" + at.write_text( + textwrap.dedent( + ''' + """A flow that is one file.""" + + from hmz.flows import Agent, flow + + @flow + def run(agents: tuple[Agent], task: str) -> None: + while True: + agents[0](task, suppress=True) + ''' + ) + ) + assert [one.code for one in checked(at)] == ["dead-loop"] + + +def test_what_is_under_skills_is_not_read(tmp_path: Path) -> None: + """A skill may ship a helper script, and it is the agents' content, not the flow's.""" + at = written( + tmp_path, + "one", + DOC + + textwrap.dedent( + """ + from hmz.flows import Agent, flow + + @flow + def run(agents: tuple[Agent], task: str) -> None: + agents[0](task) + """ + ), + skills={"helping": "# How to help\n"}, + ) + beside = at / "skills" / "helping" / "helper.py" + beside.write_text("import hmz.backends\nwhile True:\n pass\n") + assert checked(at) == () + + +def test_a_finding_says_which_file_beside_the_entry_it_is_in(tmp_path: Path) -> None: + at = written( + tmp_path, + "one", + DOC + + textwrap.dedent( + """ + from hmz.flows import Agent, flow + + @flow + def run(agents: tuple[Agent], task: str) -> None: + agents[0](task) + """ + ), + ) + (at / "helper.py").write_text( + textwrap.dedent( + ''' + """What the flow imports beside itself.""" + + def churn() -> None: + while True: + print("round and round") + ''' + ) + ) + found = checked(at) + assert [(one.code, one.where.name) for one in found] == [ + ("dead-loop", "helper.py") + ] + + +def test_the_surface_is_the_interfaces_themselves() -> None: + """What an agent may be asked is read off `agent.py`, not kept as a second list.""" + assert {"new", "clone", "spent", "hooks", "cycle", "__call__"} <= surface(Agent) + assert "board" not in surface(Agent) + assert "board" in surface(Person) + assert {"loads", "close", "stream"} <= surface(Session) + assert "rename" not in surface(Agent) + assert "rename" in surface(Driven) + + +def test_everything_offered_is_reachable() -> None: + """`offered` is what `unknown-name` trusts, so a name in it nothing answers is a lie.""" + said = offered() + assert {"flow", "Agent", "Moment", "home", "models", "backends"} <= said + assert "ClaudeCodeAgent" not in said + for name in sorted(said): + assert getattr(hmz.flows, name, None) is not None, name + + +#: Every warning a flow humanize ships or the official flowverse holds is allowed to keep. +#: rlar's loop is ended by its reviewer alone, which is the flow's own documented shape -- +#: and exactly the shape the checker exists to point at, so the warning stands. +ALLOWED_WARNINGS = {"rlar": {"unbounded-loop"}} + + +def _swept() -> list[object]: + """One parameter per flow humanize ships or the official flowverse holds now.""" + places = [("builtin", BUILTIN_AT)] + for verse in hmz.flows.flowverses(): + if verse.name == hmz.flows.OFFICIAL and verse.fetched: + places.append((verse.name, hmz.flows.holds(verse))) + held: list[object] = [] + seen_official = False + for whose, under in places: + seen_official = seen_official or whose == hmz.flows.OFFICIAL + for name in hmz.flows.offered(under): + at = entry(under, name) + if at is None: + continue + target = at.parent if at.name == ENTRY else at + held.append(pytest.param(target, name, id=f"{whose}/{name}")) + if not seen_official: + held.append( + pytest.param( + None, + "official", + id="official/unfetched", + marks=pytest.mark.skip( + reason="the official flowverse has not been fetched here" + ), + ) + ) + return held + + +@pytest.mark.parametrize(("at", "name"), _swept()) +def test_every_flow_humanize_offers_reads_clean(at: Path, name: str) -> None: + """The false-positive alarm: real flows, and exactly the warnings they are allowed.""" + found = checked(at) + errors = [one for one in found if one.severity == "error"] + assert not errors, errors + warned = {one.code for one in found if one.severity == "warning"} + assert warned == ALLOWED_WARNINGS.get(name, set()), found diff --git a/tests/test_flow_interface.py b/tests/test_flow_interface.py index 574bd84..3878c64 100644 --- a/tests/test_flow_interface.py +++ b/tests/test_flow_interface.py @@ -11,7 +11,7 @@ from __future__ import annotations import ast -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING import pytest @@ -20,6 +20,7 @@ from hmz.agents import Unrecoverable as AgentUnrecoverable from hmz.flows import BUILTIN_AT, Agent, Person, Session from hmz.flows import Unrecoverable as FlowUnrecoverable +from hmz.flows.checking import surface if TYPE_CHECKING: from collections.abc import Iterator @@ -72,28 +73,6 @@ def test_an_unrecoverable_turn_is_the_same_exception_a_flow_can_catch() -> None: assert FlowUnrecoverable is AgentUnrecoverable -def _members(protocol: type) -> set[str]: - """What one interface asks for, by name. - - Args: - protocol: The interface. - - Returns: - One name per member it declares, its own and whatever it is itself an interface of. - `__call__` is among them where it is declared, an agent and a session both being things - a flow calls; the rest of the dunders are Python's and are not part of the contract. - """ - said: set[str] = set() - for one in protocol.__mro__: - if one in (object, Protocol): - continue - held = set(vars(one)) | set(getattr(one, "__annotations__", {})) - said.update( - name for name in held if not name.startswith("_") or name == "__call__" - ) - return said - - def _answers(driver: object) -> set[str]: """What one driver has, by name. @@ -121,7 +100,7 @@ def test_the_drivers_answer_to_what_a_flow_drives() -> None: (Person, person), (Session, person.new()), ): - missing = {one for one in _members(interface) if one not in _answers(driver)} + missing = {one for one in surface(interface) if one not in _answers(driver)} assert not missing, ( f"{type(driver).__name__} does not answer to {interface.__name__}: {missing}" ) From a09797355d6656aec1ae434fdf4647ecea5bb4cd Mon Sep 17 00:00:00 2001 From: dongyun Date: Fri, 28 Aug 2026 21:42:44 +0000 Subject: [PATCH 02/18] feat(flows): add proving -- a flow driven by stubs against a clock The second of the two readings: the flow loaded and driven for real, in a subprocess per scenario, by stubs that claim every capability over the real driver base classes -- so the hooks a flow hangs fire as they would, every turn lands at once, and each costs what the scenario says. NEVER_DONE is the reviewer that never says the work is done: a loop with a bound of its own walks to the end of it in milliseconds, and one without is caught by the turn cap or killed by the parent's clock -- the executable proof that a run can end. SILENT answers every turn with nothing, which is every guard tried at once. A refused load comes back as a finding rather than a raise, and the config rules run again on the model the loading actually resolved. The proof's world sleeps for free and works in a scratch directory taken away with the process. Co-Authored-By: Claude Fable 5 --- src/hmz/flows/SPEC.md | 67 ++++ src/hmz/flows/__init__.py | 8 + src/hmz/flows/proving.py | 628 ++++++++++++++++++++++++++++++++++++++ tests/test_checking.py | 12 +- tests/test_proving.py | 382 +++++++++++++++++++++++ 5 files changed, 1091 insertions(+), 6 deletions(-) create mode 100644 src/hmz/flows/proving.py create mode 100644 tests/test_proving.py diff --git a/src/hmz/flows/SPEC.md b/src/hmz/flows/SPEC.md index f2023d8..f4c2473 100644 --- a/src/hmz/flows/SPEC.md +++ b/src/hmz/flows/SPEC.md @@ -9,6 +9,7 @@ ├── builtin ├── checking.py ├── driving.py +├── proving.py ├── skills.py └── verses.py ``` @@ -477,6 +478,72 @@ own. is `offered`: the checker states what the interface is, so a second copy of either would be the drift it checks for. +## `proving.py` + +```python +class Scenario(NamedTuple): + name: str + verdict: bool | None + answer: str + climb: float = 100_000.0 + turns: int = 200 + seconds: float = 60.0 + + +NEVER_DONE: Scenario +ALWAYS_DONE: Scenario +SILENT: Scenario + + +class Outcome(NamedTuple): + scenario: str + finished: bool + turns: int + said: str + + +class Proof(NamedTuple): + findings: tuple[Finding, ...] + outcomes: tuple[Outcome, ...] + + +def proved( + flow: str | os.PathLike[str], + *, + name: str = "", + config: Mapping[str, object] | None = None, + scenarios: tuple[Scenario, ...] = (NEVER_DONE, ALWAYS_DONE), +) -> Proof: ... +``` + +The second of the two readings: the flow loaded and driven for real, by stubs, so that what +only running the file can show is shown -- and shown in milliseconds, since every turn lands +at once and costs what the scenario says. + +- The flow MUST be run in a process of its own, one per scenario, and the clock MUST be held + by the asking process: loading a flow means running its file, and a flow that hangs, spins + or corrupts what it touches must be able to be killed without taking the checker with it. + Nothing of the flow MUST execute in the asking process. +- Every proof MUST end. A flow that takes turns is ended by the cap on them, one that takes + none by the clock, and which of the two it was MUST be said in the outcome: they are the + two ways a flow fails to stop, and the fix is different. +- The stubs MUST claim every capability there is -- every moment, a goal feature, shapes, + tools -- since what is on trial is the flow and not the agents: refusing an agent that + cannot fill a place is the loading's job, done where the agents are real. Beneath the + claims they MUST be the real driver base classes, so that the hooks a flow hangs fire as + they would under a real backend, and a `Stop` hook that refuses is a counted turn. +- A scenario MUST answer deterministically, whatever it is asked: every boolean field of a + shaped answer says its verdict, every string field says its answer, and a verdict of None + is a turn that answers nothing -- which is what a failed turn answers, so the silent + scenario is every guard tried at once. `NEVER_DONE` MUST be among the default scenarios: + the reviewer that never says done is the question every loop must have an answer to. +- The world a proof runs in MUST sleep for free and MUST work in a scratch directory taken + away with the process: the rest a loop takes between rounds and the files it writes while + being proved are no part of its shape. +- A flow the loading refuses MUST come back as a `refused-load` finding rather than a raise, + and the config rules MUST be run again on the model the loading actually resolved: a model + built out of the static reading's sight is still the one whoever sets the flow up meets. + ## `verses.py` ```python diff --git a/src/hmz/flows/__init__.py b/src/hmz/flows/__init__.py index 115ee9c..cb3c25a 100644 --- a/src/hmz/flows/__init__.py +++ b/src/hmz/flows/__init__.py @@ -66,6 +66,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: running, wanted, ) +from .proving import ALWAYS_DONE, NEVER_DONE, SILENT, Outcome, Proof, Scenario, proved from .verses import ( BUILTIN, FLOWS, @@ -111,14 +112,17 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: from hmz.backends import Model, Profile __all__ = [ + "ALWAYS_DONE", "BUILTIN", "BUILTIN_AT", "ENTRY", "EVERYWHERE", "FLOWS", "LOCAL", + "NEVER_DONE", "OFFICIAL", "PERMISSIONS", + "SILENT", "SWARM", "USER", "WINDOW", @@ -142,12 +146,15 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "NotAFlow", "Occasion", "Offer", + "Outcome", "Person", "Place", "Profile", + "Proof", "Question", "Remote", "Running", + "Scenario", "Session", "Stopped", "Unhooked", @@ -178,6 +185,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "nearest", "offered", "offers", + "proved", "resumes", "running", "wanted", diff --git a/src/hmz/flows/proving.py b/src/hmz/flows/proving.py new file mode 100644 index 0000000..b2cc5d5 --- /dev/null +++ b/src/hmz/flows/proving.py @@ -0,0 +1,628 @@ +"""A flow driven by stubs against a clock: the reading only running the file can give. + +:mod:`hmz.flows.checking` reads a flow without running it, and some of what a flow is only +running can show -- the annotation built at runtime, the config model declared in a helper, +the loop that looks bounded and is not. This is that second reading. The flow is loaded and +driven for real, in a subprocess of its own, by agents that are stubs: every turn lands at +once, answers deterministically, and costs what the scenario says a turn costs -- so a loop +held to a budget walks to the end of it in milliseconds, and what is being proved is the +flow's own shape rather than any model's mood. + +The scenarios are the questions worth asking of a loop. `NEVER_DONE` is the reviewer that +never says the work is done: a flow with a bound of its own still ends, and one without is +caught by the turn cap or killed by the clock -- which is the executable proof that a run of +it can end. `ALWAYS_DONE` is the shortest road through. `SILENT` answers every turn with +nothing, which is what a turn that failed answers, so a flow that reads a field off an +unguarded answer falls over here rather than at hour three. + +A subprocess per scenario, because loading a flow means running its file: whatever it does as +it is read -- imports, prints, mistakes -- happens in a process built to be killed, the parent +holds the clock, and nothing of the flow outlives its own proof. +""" + +from __future__ import annotations + +import inspect +import json +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Literal, + NamedTuple, + cast, + get_args, + get_origin, +) + +from .checking import Finding + +if TYPE_CHECKING: + import os + from collections.abc import Iterator, Mapping, Sequence + + from pydantic import BaseModel + + from hmz.agents import AgentBase, Event + + from .driving import Place + +__all__ = [ + "ALWAYS_DONE", + "NEVER_DONE", + "SILENT", + "Outcome", + "Proof", + "Scenario", + "proved", +] + + +class Scenario(NamedTuple): + """One way the world answers a flow, held constant for the length of a proof. + + Attributes: + name: What the scenario is called, which is what its outcome is filed under. + verdict: What every boolean field of a shaped answer says -- False is the reviewer + that never says done -- or None for a turn that answers with nothing at all, which + is what a failed turn answers. + answer: What a plain turn answers, and what every string field of a shaped one says. + climb: What each turn adds to what the agent has spent, in output tokens, so that a + loop held to a budget walks to the end of it in a handful of turns. + turns: How many turns the flow may take before it is read as one that does not stop. + seconds: How long the scenario's process may live before the clock kills it. + """ + + name: str + verdict: bool | None + answer: str + climb: float = 100_000.0 + turns: int = 200 + seconds: float = 60.0 + + +#: The reviewer that never says the work is done. A flow with a bound of its own -- a +#: budget, a cap on the rounds -- still ends here, and one that waits forever on a verdict +#: is caught by the turn cap: the executable proof that a run of it can end. +NEVER_DONE = Scenario("never-done", verdict=False, answer="did some of it") + +#: The shortest road through: every verdict is yes, so what is proved is that the flow can +#: end the way it means to. +ALWAYS_DONE = Scenario("always-done", verdict=True, answer="did it") + +#: Every turn answers with nothing, which is what a failed turn answers: a flow that reads +#: a field off an answer nobody guarded falls over here rather than hours into a run. +SILENT = Scenario("silent", verdict=None, answer="") + + +class Outcome(NamedTuple): + """How one scenario ended. + + Attributes: + scenario: Which scenario it was. + finished: Whether the flow ended on its own -- returned, or raised what it meant to. + turns: How many turns it took, as far as that was counted. + said: Why it did not finish, for one that did not: the clock, the turn cap, or the + tail of what it raised. "" for one that did. + """ + + scenario: str + finished: bool + turns: int + said: str + + +class Proof(NamedTuple): + """What driving one flow against the scenarios showed. + + Attributes: + findings: What loading it refused or the live reading found, in the same shape the + static reading answers with -- `refused-load` for a flow `driving.py` would not + take, and the config findings only the declared model itself can show. + outcomes: One per scenario, in the order they were asked. + """ + + findings: tuple[Finding, ...] + outcomes: tuple[Outcome, ...] + + +#: How long the load-only proof is given, there being no scenario to say. +_PATIENCE = 60.0 + +#: What the stubbed flow is driven with. Constant, so a proof is a proof of the flow: what +#: the task says cannot matter to agents that answer the same thing whatever they are told. +_TASK = "the task this proof drives the flow on" + + +def proved( + flow: str | os.PathLike[str], + *, + name: str = "", + config: Mapping[str, object] | None = None, + scenarios: tuple[Scenario, ...] = (NEVER_DONE, ALWAYS_DONE), +) -> Proof: + """Loads a flow in a subprocess and drives it with stubs, once per scenario. + + Args: + flow: The flow: its directory, its file, or the name `-f` takes. + name: Which of the flows the file holds, or "" for the one it holds under its own + name -- the half after the colon, for whoever has it separately. + config: What to set the flow up with, read back through the flow's own model exactly + as a run of it would, or None for a flow left to its defaults. + scenarios: The worlds to drive it against, each in a process of its own. Empty proves + only that it loads: the flow is declared and its config model read, and nothing + takes a turn. + + Returns: + The findings and one outcome per scenario. A finding is something to fix; an outcome + that did not finish is a flow that could not end in that world, said with why. + """ + from . import find, inside + + # Resolved here, where names still mean what the caller meant: the child runs in a + # scratch directory of its own, against which a relative path names nothing. + at = find(str(flow)) + wanted = name or inside(str(flow)) + where = Path(at) + findings: list[Finding] = [] + outcomes: list[Outcome] = [] + seen: set[tuple[str, str]] = set() + asked: tuple[Scenario | None, ...] = scenarios or (None,) + for scenario in asked: + told = _asked(at, wanted, config, scenario) + if isinstance(told, Outcome): + outcomes.append(told) + continue + for one in told.get("findings", ()): + key = (str(one["code"]), str(one["said"])) + if key not in seen: + seen.add(key) + severity: Literal["error", "warning"] = ( + "error" if one["severity"] == "error" else "warning" + ) + findings.append(Finding(key[0], severity, where, 0, key[1])) + refused = told.get("refused") + if refused is not None: + key = ("refused-load", str(refused)) + if key not in seen: + seen.add(key) + findings.append(Finding(key[0], "error", where, 0, key[1])) + if scenario is not None: + outcomes.append( + Outcome( + scenario.name, + finished=False, + turns=0, + said="nothing ran: the flow could not be loaded", + ) + ) + continue + if scenario is not None: + outcomes.append( + Outcome( + scenario.name, + finished=bool(told.get("finished")), + turns=int(told.get("turns", 0)), + said=str(told.get("said", "")), + ) + ) + return Proof(tuple(findings), tuple(outcomes)) + + +def _asked( + flow: str, + name: str, + config: Mapping[str, object] | None, + scenario: Scenario | None, +) -> dict[str, Any] | Outcome: + """One scenario, asked of a child process holding the clock over it. + + Args: + flow: The flow, as :func:`proved` was given it. + name: Which of the file's flows. + config: What to set it up with, or None. + scenario: The world to drive it in, or None to only load it. + + Returns: + What the child answered, or the outcome of a child that could not answer: one the + clock killed, or one that died without saying why in the one line this reads. + """ + called = scenario.name if scenario is not None else "" + spec = json.dumps( + { + "name": name, + "config": dict(config) if config is not None else None, + "scenario": scenario._asdict() if scenario is not None else None, + } + ) + patience = scenario.seconds if scenario is not None else _PATIENCE + # A scratch directory to work in, taken away with the process: what a flow writes while + # it is being proved is part of the proof, not part of anybody's repository. + with tempfile.TemporaryDirectory(prefix="hmz-proving-") as scratch: + try: + done = subprocess.run( + [sys.executable, "-m", "hmz.flows.proving", flow, spec], + capture_output=True, + text=True, + check=False, + timeout=patience, + cwd=scratch, + ) + except subprocess.TimeoutExpired: + return Outcome( + called, + finished=False, + turns=0, + said=f"still running after {patience:g}s -- nothing inside the flow " + "ended it, so the clock did", + ) + # The last line that is the child's: a flow prints whatever it prints, so the answer is + # found from the end rather than trusted to be alone. + for line in reversed(done.stdout.splitlines()): + try: + held = json.loads(line) + except ValueError: + continue + if isinstance(held, dict) and "proving" in held: + return cast("dict[str, Any]", held["proving"]) + tail = "\n".join(done.stderr.strip().splitlines()[-3:]) + return Outcome( + called, + finished=False, + turns=0, + said=f"the flow's process ended without answering -- {tail or 'and said nothing'}", + ) + + +# --------------------------------------------------------------------------------------- +# The child: loads the flow, builds the stubs, and drives it. Run as `-m hmz.flows.proving` +# with the flow and the scenario as its two arguments, and answers with one JSON line. +# --------------------------------------------------------------------------------------- + + +def _rested(seconds: float) -> None: + """A sleep that has already happened, which is what the stubs' world does with rests. + + Args: + seconds: How long the flow meant to wait, which the proof does not. + """ + del seconds + + +class _Enough(BaseException): + """The turn cap, raised past everything a flow catches: a proof is over when it is. + + A `BaseException`, so that a flow's own `except Exception` -- which is a fine thing for + a loop to write around a turn -- does not swallow the one thing that ends its proof. + """ + + +class _Steps: + """The turns taken so far, shared by every stub of one proof.""" + + def __init__(self, cap: int) -> None: + self.cap = cap + self.taken = 0 + + def step(self) -> None: + """Counts one turn, and ends the proof on the one past the cap.""" + self.taken += 1 + if self.taken > self.cap: + raise _Enough + + +def _driven(flow: str, spec: dict[str, Any]) -> dict[str, Any]: + """Loads one flow and, given a scenario, drives it with stubs to whatever end. + + Args: + flow: The flow, as the parent was given it. + spec: The parent's ask: the name inside the file, the config, and the scenario -- + or None for a proof that only loads. + + Returns: + What the parent folds into the proof: `refused` for a flow that would not load, + `findings` off the live config model, and how driving it went. + """ + import time + + from .driving import NotAFlow, declares, set_up + + # A proof's world sleeps for free. The rest a loop takes between rounds is part of its + # manners and no part of its shape, and it is the shape on trial: a loop that rests + # five seconds a round is not five hundred seconds more legal than one that does not. + # Patched before the flow is even loaded, so a `from time import sleep` reads this one. + time.sleep = _rested + + named = f"{flow}:{spec['name']}" if spec["name"] else flow + scenario = Scenario(**spec["scenario"]) if spec["scenario"] else None + try: + run, places, make, setting, mark = declares(named) + except NotAFlow as refused: + return {"refused": str(refused)} + except BaseException as raised: # noqa: BLE001 -- reported, in a process built for it + return {"refused": f"the flow's own file raised as it was read -- {raised}"} + answered: dict[str, Any] = {"findings": _styled(setting)} + given = None + if spec["config"] is not None: + try: + given = set_up(named, setting, spec["config"]) + except NotAFlow as refused: + answered["refused"] = str(refused) + return answered + if scenario is None: + return answered + steps = _Steps(scenario.turns) + settings = () if setting is None else (given,) + held: tuple[dict[str, Any], ...] = ({},) if mark.resumable else () + try: + out = run(make(_crewed(places, scenario, steps)), _TASK, *settings, *held) + if inspect.isawaitable(out): + import asyncio + + asyncio.run(_awaited(out)) + finished, turns, said = True, steps.taken, "" + except _Enough: + finished, turns = False, scenario.turns + said = ( + f"still going after {scenario.turns} turns -- nothing inside the flow " + "ended it, and a loop is legal when something inside it can end it" + ) + except BaseException: # noqa: BLE001 -- the flow's own crash is the outcome + import traceback + + tail = traceback.format_exc().strip().splitlines() + finished, turns, said = False, steps.taken, "\n".join(tail[-3:]) + answered.update(finished=finished, turns=turns, said=said) + return answered + + +async def _awaited(out: Any) -> None: + """One awaitable flow, awaited: what `asyncio.run` takes is a coroutine.""" + await out + + +def _styled(setting: type[BaseModel] | None) -> list[dict[str, str]]: + """The config findings only the live model can show, said as the static reading says. + + The model a flow declares may be built anywhere -- a helper module, a call -- and the + static reading only checks the ones written in plain sight. This is the same two rules + against the model `declares` actually resolved. + + Args: + setting: The model, or None for a flow that takes no setting up. + + Returns: + One finding per thing found, as plain values for the one JSON line home. + """ + if setting is None: + return [] + found: list[dict[str, str]] = [] + config = setting.model_config + if not (config.get("extra") == "forbid" or config.get("frozen") is True): + found.append( + { + "code": "loose-config", + "severity": "warning", + "said": "the config takes anything -- set model_config to extra: " + "forbid or frozen: True, so a setting that is misspelled is refused " + "rather than quietly ignored", + } + ) + for name, field in setting.model_fields.items(): + if not field.description: + found.append( + { + "code": "unsaid-field", + "severity": "warning", + "said": f"the config field {name!r} says nothing about itself -- " + "give it a Field(description=...), which is what whoever sets the " + "flow up is shown", + } + ) + return found + + +def _crewed( + places: Sequence[Place], scenario: Scenario, steps: _Steps +) -> list[AgentBase]: + """The stub agents for one flow's places, all of one scenario and one turn count. + + Built inside a function because the drivers are heavy and the parent half of this + module is imported by `hmz.flows` itself: only a child actually proving a flow pays + for them. + + The stubs claim every capability there is -- every moment, a goal feature, shapes, + tools -- because what is being proved is the flow and not the agents: a flow legal on + the widest backend is refused for a narrower one where the agents are chosen, which is + `driving.py`'s job and not this one's. Everything else is the real base classes, so + the hooks a flow hangs fire exactly as they would under a real backend -- a `Stop` + hook that refuses sends a stub on again, and that continuation is a counted turn. + + Args: + places: What the flow declared. + scenario: The world the stubs answer from. + steps: The shared turn count, whose cap ends a proof nothing else ends. + + Returns: + One agent per place, the person's included. + """ + from hmz.agents import ( + AgentBase, + AgentConfig, + Event, + HumanAgent, + Moment, + SessionBase, + Usage, + ) + from hmz.agents.human import HumanSession + + class StubSession(SessionBase): + """A turn that lands at once and answers what the scenario says.""" + + shapes: ClassVar[bool] = True + takes_tools: ClassVar[bool] = True + + def _stream( + self, prompt: str, *, schema: type[BaseModel] | None = None + ) -> Iterator[Event]: + del prompt + steps.step() + if self._id is None: + self._adopt(f"stub-{id(self)}-{steps.taken}") + spent = Usage(output=scenario.climb) + self._spends(spent) + yield Event(kind="result", text=_said(schema, scenario), spent=spent) + + def _pursue(self, objective: str) -> str: + del objective + steps.step() + self._spends(Usage(output=scenario.climb)) + return scenario.answer + + class StubAgent(AgentBase): + """An agent claiming every capability, so only the flow is on trial.""" + + moments: ClassVar[frozenset[Moment]] = frozenset(Moment) + pursues: ClassVar[bool] = True + + def new(self, cwd: str | os.PathLike[str] | None = None) -> StubSession: + return StubSession(self, cwd) + + class StubTalk(HumanSession): + """The person's answers, deterministic: the scenario's, not a prompt's.""" + + shapes: ClassVar[bool] = True + + def stream( + self, prompt: str, *, schema: type[BaseModel] | None = None + ) -> Iterator[Event]: + # Overridden whole, as the real person's session is: their turn is not one + # being watched, and not one bracketed by an agent's moments. It still counts + # against the cap -- a flow that loops on asking forever is a flow that does + # not stop, whoever it is asking. + del prompt + steps.step() + yield Event(kind="result", text=_said(schema, scenario)) + + class StubPerson(HumanAgent): + """The person at the prompt, answering as the scenario has them answer.""" + + def new(self, cwd: str | os.PathLike[str] | None = None) -> StubTalk: + return StubTalk(self, cwd) + + return [ + StubPerson() + if place.person + else StubAgent(AgentConfig(model="stub", effort=""), name=place.name or None) + for place in places + ] + + +def _said(schema: type[BaseModel] | None, scenario: Scenario) -> str: + """What one stub turn answers with, as the text the base classes read back. + + Args: + schema: The shape the turn was held to, or None for a plain turn. + scenario: The world answering. + + Returns: + The scenario's answer for a plain turn; for a shaped one, the fabricated model as + its own JSON -- or "", which the base reads back as no answer at all, for the silent + scenario and for a shape nothing can be fabricated for. + """ + if schema is None: + return scenario.answer + if scenario.verdict is None: + return "" + from pydantic import ValidationError + + try: + return schema.model_validate(_made(schema, scenario)).model_dump_json() + except ValidationError: + return "" + + +def _made(schema: type[BaseModel], scenario: Scenario) -> dict[str, Any]: + """A shaped answer fabricated field by field, deterministically. + + Every boolean says the scenario's verdict -- which is what makes `NEVER_DONE` the + reviewer that never says done, whatever the field is called -- and every string says + its answer. The rest is the quietest legal value: a default where the field has one, + the first of a literal's few, an empty list, a zero, a nested shape made the same way. + + Args: + schema: The shape. + scenario: The world answering. + + Returns: + The fields, ready to be read back through the model. + """ + return { + name: _filled(field.annotation, field, scenario) + for name, field in schema.model_fields.items() + } + + +def _unioned(kind: Any) -> tuple[Any, ...]: + """One annotation and, for a union, what it is a union of. + + Only a union: the arguments of a `list[str]` are what is inside it, not what the field + itself may be, and a list of strings answered as one string would be the confusion. + """ + import types + import typing + + if get_origin(kind) in (types.UnionType, typing.Union): + return (kind, *get_args(kind)) + return (kind,) + + +def _filled(kind: Any, field: Any, scenario: Scenario) -> Any: + """One field's value, off its annotation. + + Args: + kind: What the field was annotated with, unions unwrapped as they are met. + field: The field itself, for the default it may carry. + scenario: The world answering. + + Returns: + The value. + """ + from pydantic import BaseModel + + for said in _unioned(kind): + if said is bool: + return scenario.verdict + if said is str: + return scenario.answer + if get_origin(said) is Literal: + return get_args(said)[0] + if field is not None and not field.is_required(): + return field.get_default(call_default_factory=True) + for said in _unioned(kind): + if said in (int, float): + return 0 + if get_origin(said) in (list, tuple, set, frozenset): + return [] + if get_origin(said) is dict: + return {} + if isinstance(said, type) and issubclass(said, BaseModel): + return _made(said, scenario) + return None + + +def _main(argv: list[str]) -> None: + """The child's whole life: one flow, one spec, one JSON line back.""" + flow, spec = argv + said = json.dumps({"proving": _driven(flow, json.loads(spec))}) + sys.stdout.write(said + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + _main(sys.argv[1:]) diff --git a/tests/test_checking.py b/tests/test_checking.py index d1e78ae..7fe6da7 100644 --- a/tests/test_checking.py +++ b/tests/test_checking.py @@ -772,9 +772,7 @@ def churn() -> None: ) ) found = checked(at) - assert [(one.code, one.where.name) for one in found] == [ - ("dead-loop", "helper.py") - ] + assert [(one.code, one.where.name) for one in found] == [("dead-loop", "helper.py")] def test_the_surface_is_the_interfaces_themselves() -> None: @@ -805,9 +803,11 @@ def test_everything_offered_is_reachable() -> None: def _swept() -> list[object]: """One parameter per flow humanize ships or the official flowverse holds now.""" places = [("builtin", BUILTIN_AT)] - for verse in hmz.flows.flowverses(): - if verse.name == hmz.flows.OFFICIAL and verse.fetched: - places.append((verse.name, hmz.flows.holds(verse))) + places.extend( + (verse.name, hmz.flows.holds(verse)) + for verse in hmz.flows.flowverses() + if verse.name == hmz.flows.OFFICIAL and verse.fetched + ) held: list[object] = [] seen_official = False for whose, under in places: diff --git a/tests/test_proving.py b/tests/test_proving.py new file mode 100644 index 0000000..7c1178a --- /dev/null +++ b/tests/test_proving.py @@ -0,0 +1,382 @@ +"""A flow driven by stubs against a clock, held to ending every proof and saying why. + +The scenarios are the questions: a budget loop walks to the end of its budget when the +reviewer never says done, a loop whose only exit is that verdict is caught by the turn cap, +a flow that takes no turns at all is killed by the clock, and the silent world -- every turn +answering nothing -- is every guard tried at once. Each proof is a subprocess, so these +tests are also the test that the child half answers the parent at all. +""" + +from __future__ import annotations + +import textwrap +from typing import TYPE_CHECKING, Literal + +from pydantic import BaseModel, Field + +from hmz.flows.proving import ( + ALWAYS_DONE, + NEVER_DONE, + SILENT, + Scenario, + _made, + _said, + proved, +) +from tests.stubs import written + +if TYPE_CHECKING: + from pathlib import Path + +#: A world that says no quickly: few turns allowed, and a short clock, so a flow that +#: cannot end is caught in test time rather than in a minute apiece. +QUICKLY = Scenario("never-done", verdict=False, answer="did some of it", turns=6, seconds=20.0) + +#: A budget loop in miniature: each stub turn climbs 100k output tokens, so three turns +#: spend the 250k this flow holds itself to. +BUDGETED = ''' +"""A loop held to a budget of its own.""" + +import time + +from hmz.flows import Agent, flow + + +@flow +def run(agents: tuple[Agent], task: str) -> None: + (agent,) = agents + while True: + agent(task, suppress=True) + if agent.spent().output >= 250_000: + return + time.sleep(5) +''' + +#: A loop whose one way out is the reviewer's verdict, which is rlar's shape. +VERDICT_ONLY = ''' +"""A loop only its reviewer can end.""" + +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + + +class Review(BaseModel): + model_config = {"extra": "forbid"} + + done: bool = Field(description="whether it is over") + + +@flow +def run(agents: tuple[Agent, Agent], task: str) -> None: + working = agents[0].new() + while True: + working(task, suppress=True) + review = agents[1](task, suppress=True, schema=Review) + if review is not None and review.done: + return +''' + + +def test_a_budget_loop_ends_when_the_reviewer_never_says_done(tmp_path: Path) -> None: + """The point of the whole module: bounded flows end in the worst world there is.""" + at = written(tmp_path, "one", textwrap.dedent(BUDGETED)) + proof = proved(at, scenarios=(NEVER_DONE,)) + assert proof.findings == () + (outcome,) = proof.outcomes + assert outcome.finished + assert outcome.turns == 3 + assert outcome.said == "" + + +def test_a_verdict_only_loop_is_caught_by_the_turn_cap(tmp_path: Path) -> None: + at = written(tmp_path, "one", textwrap.dedent(VERDICT_ONLY)) + proof = proved(at, scenarios=(QUICKLY, ALWAYS_DONE)) + never, always = proof.outcomes + assert not never.finished + assert never.turns == QUICKLY.turns + assert f"{QUICKLY.turns} turns" in never.said + # The same loop under a reviewer that says yes ends the way it means to. + assert always.finished + assert always.turns == 2 + + +def test_a_flow_that_takes_no_turns_is_killed_by_the_clock(tmp_path: Path) -> None: + at = written( + tmp_path, + "one", + textwrap.dedent( + ''' + """A loop with no turns for the cap to count.""" + + from hmz.flows import Agent, flow + + + @flow + def run(agents: tuple[Agent], task: str) -> None: + held = 0 + while True: + held += 1 + ''' + ), + ) + proof = proved(at, scenarios=(Scenario("spin", None, "", seconds=3.0),)) + (outcome,) = proof.outcomes + assert not outcome.finished + assert "still running after 3s" in outcome.said + + +def test_a_crash_is_the_outcome_with_its_last_words(tmp_path: Path) -> None: + at = written( + tmp_path, + "one", + textwrap.dedent( + ''' + """A flow that falls over.""" + + from hmz.flows import Agent, flow + + + @flow + def run(agents: tuple[Agent], task: str) -> None: + raise RuntimeError("the kettle is on fire") + ''' + ), + ) + proof = proved(at, scenarios=(ALWAYS_DONE,)) + (outcome,) = proof.outcomes + assert not outcome.finished + assert "the kettle is on fire" in outcome.said + + +def test_what_is_not_a_flow_is_a_refused_load(tmp_path: Path) -> None: + at = written(tmp_path, "one", '"""Not a flow."""\n\nx = 1\n') + proof = proved(at, scenarios=(ALWAYS_DONE,)) + assert [one.code for one in proof.findings] == ["refused-load"] + assert proof.findings[0].severity == "error" + assert "marked @flow()" in proof.findings[0].said + (outcome,) = proof.outcomes + assert not outcome.finished + + +#: A flow whose bound is its config, for proving the config reaches it. +CONFIGURED = ''' +"""A loop held to whatever budget it is set up with.""" + +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + + +class Config(BaseModel): + model_config = {"extra": "forbid"} + + budget: float = Field(default=1.0, ge=0, description="millions of output tokens") + + +@flow +def run(agents: tuple[Agent], task: str, config: Config | None = None) -> None: + (agent,) = agents + held = config or Config() + while True: + agent(task, suppress=True) + if agent.spent().output >= held.budget * 1_000_000: + return +''' + + +def test_a_config_is_read_back_through_the_flows_own_model(tmp_path: Path) -> None: + at = written(tmp_path, "one", textwrap.dedent(CONFIGURED)) + # 0.3 million at 100k a turn is three turns: the setting reached the loop. + proof = proved(at, config={"budget": 0.3}, scenarios=(NEVER_DONE,)) + assert proof.findings == () + assert proof.outcomes[0].finished + assert proof.outcomes[0].turns == 3 + # And one the model refuses is refused before anything runs. + refused = proved(at, config={"budget": "a lot"}, scenarios=(NEVER_DONE,)) + assert [one.code for one in refused.findings] == ["refused-load"] + assert not refused.outcomes[0].finished + + +def test_an_empty_proof_only_loads_and_reads_the_live_config(tmp_path: Path) -> None: + at = written(tmp_path, "one", textwrap.dedent(CONFIGURED)) + proof = proved(at, scenarios=()) + assert proof == ((), ()) + # A config the static reading cannot see is still read here, off the model itself. + loose = written( + tmp_path, + "loose", + textwrap.dedent( + ''' + """A flow with a config that says nothing about itself.""" + + from hmz.flows import Agent, flow + from pydantic import BaseModel + + + class Config(BaseModel): + budget: float = 1.0 + + + @flow + def run(agents: tuple[Agent], task: str, config: Config | None = None) -> None: + agents[0](task) + ''' + ), + ) + told = proved(loose, scenarios=()) + assert [one.code for one in told.findings] == ["loose-config", "unsaid-field"] + assert {one.severity for one in told.findings} == {"warning"} + + +#: A flow reading a shaped answer: one guarded, one not, for the silent world to tell apart. +GUARDED = ''' +"""A flow that guards what a turn answered.""" + +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + + +class Review(BaseModel): + model_config = {"extra": "forbid"} + + done: bool = Field(description="whether it is over") + + +@flow +def run(agents: tuple[Agent], task: str) -> None: + (agent,) = agents + while True: + review = agent(task, suppress=True, schema=Review) + if review is not None and review.done: + return + if agent.spent().output >= 200_000: + return +''' + +UNGUARDED = ''' +"""A flow that reads a field off whatever came back.""" + +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + + +class Review(BaseModel): + model_config = {"extra": "forbid"} + + done: bool = Field(description="whether it is over") + + +@flow +def run(agents: tuple[Agent], task: str) -> None: + review = agents[0](task, suppress=True, schema=Review) + print(review.done) +''' + + +def test_the_silent_world_is_every_guard_tried_at_once(tmp_path: Path) -> None: + guarded = written(tmp_path, "guarded", textwrap.dedent(GUARDED)) + proof = proved(guarded, scenarios=(SILENT,)) + assert proof.outcomes[0].finished # None answers taken again, until the budget + unguarded = written(tmp_path, "unguarded", textwrap.dedent(UNGUARDED)) + told = proved(unguarded, scenarios=(SILENT,)) + assert not told.outcomes[0].finished + assert "AttributeError" in told.outcomes[0].said + + +def test_the_person_answers_what_the_scenario_says(tmp_path: Path) -> None: + at = written( + tmp_path, + "one", + textwrap.dedent( + ''' + """A conversation, over when the person says nothing.""" + + from typing import NamedTuple + + from hmz.flows import Agent, Person, flow + + + class Chat(NamedTuple): + assistant: Agent + human: Person + + + @flow + def run(agents: Chat, task: str) -> None: + conversation = agents.assistant.new() + said = task + while said: + answered = conversation(said, suppress=True) + said = agents.human(answered) + ''' + ), + ) + # Nobody at the prompt: the flow does the one thing it was given and stops. + silent = proved(at, scenarios=(SILENT,)) + assert silent.outcomes[0].finished + assert silent.outcomes[0].turns == 2 # the assistant's turn, and the person's "" + # A person who never stops talking is a conversation that never ends: the cap's. + chatty = proved(at, scenarios=(Scenario("chatty", True, "go on", turns=9),)) + assert not chatty.outcomes[0].finished + + +def test_an_async_flow_is_awaited(tmp_path: Path) -> None: + at = written( + tmp_path, + "one", + textwrap.dedent( + ''' + """A flow written as a coroutine.""" + + from hmz.flows import Agent, flow + + + @flow + async def run(agents: tuple[Agent], task: str) -> None: + await agents[0].aturn(task, suppress=True) + ''' + ), + ) + proof = proved(at, scenarios=(ALWAYS_DONE,)) + (outcome,) = proof.outcomes + assert outcome.finished, outcome + assert outcome.turns == 1 + + +class Nested(BaseModel): + said: str + fine: bool + + +class Shaped(BaseModel): + done: bool + notes: str + stage: Literal["draft", "final"] + rounds: int = 4 + weight: float + parts: list[str] + inner: Nested + extra: str | None = None + + +def test_a_shaped_answer_is_fabricated_field_by_field() -> None: + made = _made(Shaped, NEVER_DONE) + held = Shaped.model_validate(made) + assert held.done is False # the verdict, whatever the field is called + assert held.notes == NEVER_DONE.answer + assert held.stage == "draft" # the first of a literal's few + assert held.rounds == 4 # the default, where the field has one + assert held.weight == 0 + assert held.parts == [] + assert held.inner == Nested(said=NEVER_DONE.answer, fine=False) + assert held.extra == NEVER_DONE.answer # a string, even behind a union + + +def test_a_shape_nothing_can_be_fabricated_for_answers_nothing() -> None: + class Impossible(BaseModel): + count: int = Field(ge=5) # fabricated as 0, which the model then refuses + + assert _said(Impossible, ALWAYS_DONE) == "" + # And the silent world answers nothing whatever the shape. + assert _said(Shaped, SILENT) == "" + assert _said(None, SILENT) == "" + assert _said(None, ALWAYS_DONE) == ALWAYS_DONE.answer From bee485effc722c0602cd2ad34940d2ddffdb76ef Mon Sep 17 00:00:00 2001 From: dongyun Date: Fri, 28 Aug 2026 21:57:19 +0000 Subject: [PATCH 03/18] feat(flows): add the capability catalogue the compiler steers by catalogue() reads what this installed humanize serves at call time: the primitives every backend has, each moment outside EVERYWHERE with the backends whose drivers declare it, the shape a turn can be held to, the tools a flow may offer, and the goal feature -- all off the live enum, the DRIVEN table and the interfaces surface() reads, so a flow written against the catalogue is written against this installation rather than against a snapshot that drifts. briefed() renders it as the one page a compiler -- or a person choosing what to build on -- steers by, split into what every backend serves and what has to be declared on the place. Co-Authored-By: Claude Fable 5 --- src/hmz/flows/__init__.py | 5 +- src/hmz/flows/checking.py | 240 +++++++++++++++++++++++++++++++++++++- tests/test_catalogue.py | 90 ++++++++++++++ tests/test_proving.py | 4 +- 4 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 tests/test_catalogue.py diff --git a/src/hmz/flows/__init__.py b/src/hmz/flows/__init__.py index cb3c25a..ecf8627 100644 --- a/src/hmz/flows/__init__.py +++ b/src/hmz/flows/__init__.py @@ -52,7 +52,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: from typing import TYPE_CHECKING, Any, NamedTuple, overload from .agent import Agent, Driven, Person, Session -from .checking import Finding, checked +from .checking import Capability, Finding, briefed, catalogue, checked from .driving import ( NotAFlow, Place, @@ -129,6 +129,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "Agent", "AgentConfig", "AgentDefaults", + "Capability", "Driven", "Event", "Failed", @@ -164,7 +165,9 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "about", "at", "backends", + "briefed", "carries", + "catalogue", "checked", "configures", "container", diff --git a/src/hmz/flows/checking.py b/src/hmz/flows/checking.py index edcbf91..85491e9 100644 --- a/src/hmz/flows/checking.py +++ b/src/hmz/flows/checking.py @@ -22,6 +22,7 @@ from __future__ import annotations import ast +import contextlib from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Literal, NamedTuple, Protocol @@ -32,7 +33,15 @@ import os from collections.abc import Iterator -__all__ = ["Finding", "checked", "offered", "surface"] +__all__ = [ + "Capability", + "Finding", + "briefed", + "catalogue", + "checked", + "offered", + "surface", +] class Finding(NamedTuple): @@ -1557,3 +1566,232 @@ def _sleeps(body: list[ast.stmt]) -> bool: continue return False return True + + +# --------------------------------------------------------------------------------------- +# The capability catalogue: what this installed humanize serves, read off it live. +# --------------------------------------------------------------------------------------- + + +class Capability(NamedTuple): + """One thing a flow may build on, and which backends serve it. + + Attributes: + name: What it is called: a primitive by one word, and a moment only some backends + reach as `moment:`. + backends: The backends that serve it, or empty for one every backend serves. + said: What the ask looks like, with the code spelled out: this is what a compiler + or a person choosing what to build on is shown. + """ + + name: str + backends: frozenset[str] + said: str + + +def catalogue() -> tuple[Capability, ...]: + """Everything a flow may build on here, read off the installed interface at call time. + + At call time rather than written down, because this is what keeps a generated flow + honest across versions: the moments come off the live enum, the backend sets off the + driver classes' own declarations, and the asks off the same interfaces `surface` reads + -- so what the catalogue promises is what this installation serves, not what some + edition of it once did. + + Returns: + One capability apiece: the primitives every backend serves, then what only some do + -- each moment outside `EVERYWHERE`, the shape a turn can be held to, the tools a + flow may offer, and the goal feature. + """ + import inspect + import sys as running + + from hmz.agents import DRIVEN, EVERYWHERE, Moment + + agents = {name: held[0] for name, held in DRIVEN.items()} + sessions: dict[str, type] = {} + for name, cls in agents.items(): + # The session class, read off what `new` says it answers with: the class itself + # is what carries `shapes` and `takes_tools`. By the name in the driver's own + # module rather than through `get_type_hints`, which would ask every annotation + # in the signature to resolve -- and a driver is free to keep `os` under + # TYPE_CHECKING. + told: object = None + with contextlib.suppress(Exception): + told = inspect.signature(cls.new).return_annotation + if isinstance(told, str): + told = vars(running.modules[cls.__module__]).get(told) + if isinstance(told, type): + sessions[name] = told + held: list[Capability] = [ + Capability( + "turns", + frozenset(), + "one turn in a session of its own -- agent(prompt, suppress=True) -- or many " + "at once with agent.batch(prompts); suppress=True makes a failed turn answer " + "'' (or None, for a shaped one) instead of raising", + ), + Capability( + "sessions", + frozenset(), + "one conversation held across turns -- session = agent.new(cwd=...) then " + "session(prompt) -- and dropping the session is how a flow forgets", + ), + Capability( + "schema", + frozenset(), + "a turn read back as an object -- session(prompt, suppress=True, " + "schema=Model) answers the model or None, and the answer is guarded before a " + "field is read off it", + ), + Capability( + "budgets", + frozenset(), + "what a loop's bound reads -- agent.spent().output climbs as the run spends, " + "and session.rate() and session.juice() say how fast", + ), + Capability( + "hooks", + frozenset(), + "a word in at the moments of a turn -- agent.hooks.on(Moment.STOP, hook) " + "hangs a callable, and a Stop hook that refuses sends the agent on with what " + "it said", + ), + Capability( + "subflows", + frozenset(), + "one flow runs another -- load('official/rlar')(agents, task) -- found by " + "the same name -f takes, and refused where it is asked for if nothing " + "answers to it", + ), + Capability( + "person", + frozenset(), + "the person at the prompt is a place like any other -- agents that include a " + "Person field -- and person(said) asks them; run where nobody is at a " + "prompt, they answer nothing, and a flow written to stop on nothing stops", + ), + Capability( + "board", + frozenset(), + "named lines the flow and the person both write on and neither waits at -- " + "person.board.put('todo', said) and person.board.get('todo')", + ), + Capability( + "state", + frozenset(), + "a flow marked @flow(resumable=True) is handed a dict as its last argument, " + "holding what it wrote there last time -- and clears it when the run is over", + ), + Capability( + "config", + frozenset(), + "what a flow can be set up with is a pydantic model third argument -- " + "config: Config | None = None -- whose model_config refuses extras and whose " + "every field carries Field(description=...)", + ), + Capability( + "skills", + frozenset(), + "a flow's own skills live in its skills/ directory, one directory per skill " + "with a SKILL.md in it, and a session says which it carries with " + "session.loads([...])", + ), + Capability( + "clone", + frozenset(), + "an agent set up differently is another agent -- " + "agent.clone(config=replace(agent.config, effort='high')) -- having opened " + "nothing and spent nothing", + ), + Capability( + "moments", + frozenset(), + "the moments every backend reaches, for a hook to hang on: " + + ", ".join(f"Moment.{one.name}" for one in Moment if one in EVERYWHERE), + ), + ] + for moment in Moment: + if moment in EVERYWHERE: + continue + held.append( + Capability( + f"moment:{moment.value}", + frozenset( + name for name, cls in agents.items() if moment in cls.moments + ), + f"Moment.{moment.name} is reached only where the backend says so -- " + f"declare it on the place, Annotated[Agent, Moment.{moment.name}], and " + "the run is refused an agent that cannot run it before its first turn", + ) + ) + held.append( + Capability( + "shapes", + frozenset( + name for name, one in sessions.items() if getattr(one, "shapes", False) + ), + "a turn held to the shape rather than asked to keep to it -- every backend " + "takes schema=, and these are the ones the answer is certain on", + ) + ) + held.append( + Capability( + "tools", + frozenset( + name + for name, one in sessions.items() + if getattr(one, "takes_tools", False) + ), + "a flow's own callbacks put in front of the agent -- " + "session.offers([Tool(...)]) -- which a backend not among these refuses; " + "type(session).takes_tools says so beforehand", + ) + ) + pursuing = frozenset(name for name, cls in agents.items() if cls.pursues) + held.append( + Capability( + "pursue", + pursuing, + "the backend's own goal feature -- session.pursue(objective) keeps the " + "agent going until it decides for itself the objective is met", + ) + ) + held.append( + Capability( + "goal", + pursuing, + "a place run under that feature declares it -- Annotated[Agent, Goal] -- " + "and is refused an agent whose backend has none before the first turn", + ) + ) + return tuple(held) + + +def briefed() -> str: + """The catalogue rendered as the one page a compiler steers by. + + Returns: + What every backend serves, then what only some do -- each with the backends that + do, so that a flow built on one can say where it runs. + """ + held = catalogue() + lines = [ + "What a flow may build on here, read off this installed humanize.", + "", + "Every backend:", + ] + lines.extend(f"- {one.name}: {one.said}" for one in held if not one.backends) + lines += [ + "", + ( + "Only some backends -- a flow built on one of these says so where it " + "declares the place, and is refused an unfit agent before the first turn:" + ), + ] + lines.extend( + f"- {one.name} ({', '.join(sorted(one.backends))}): {one.said}" + for one in held + if one.backends + ) + return "\n".join(lines) diff --git a/tests/test_catalogue.py b/tests/test_catalogue.py new file mode 100644 index 0000000..5967ae0 --- /dev/null +++ b/tests/test_catalogue.py @@ -0,0 +1,90 @@ +"""The capability catalogue, held to saying only what this installation actually serves. + +Honesty tests: every name the catalogue uses is a real moment, a real backend or a real +member of the interfaces a flow is written against, and every backend set is exactly what +the live driver classes declare. The catalogue is what a compiler steers by, and a +capability it invented -- or one that drifted from the drivers -- is a generated flow that +asks for what nothing serves. +""" + +from __future__ import annotations + +from hmz.agents import DRIVEN, EVERYWHERE, Moment +from hmz.flows import Agent, Person, Session +from hmz.flows.checking import briefed, catalogue, offered, surface + + +def test_every_conditional_moment_is_real_and_exactly_whose_drivers_say() -> None: + told = {one.name: one for one in catalogue() if one.name.startswith("moment:")} + outside = {one for one in Moment if one not in EVERYWHERE} + assert set(told) == {f"moment:{one.value}" for one in outside} + for moment in outside: + assert told[f"moment:{moment.value}"].backends == frozenset( + name for name, (cls, _) in DRIVEN.items() if moment in cls.moments + ) + + +def test_the_backend_facts_are_the_drivers_own() -> None: + told = {one.name: one.backends for one in catalogue()} + assert told["pursue"] == frozenset( + name for name, (cls, _) in DRIVEN.items() if cls.pursues + ) + assert told["goal"] == told["pursue"] + # The two facts a session carries, checked against the backends known to carry them: + # the sets themselves are read off the session classes, so what is pinned here is that + # the reading reaches them at all. + assert {"claude", "codex"} <= told["shapes"] + assert "claude" in told["tools"] + for one in catalogue(): + assert one.backends <= set(DRIVEN), one.name + + +def test_every_ask_the_catalogue_spells_is_on_the_interfaces() -> None: + """The primitives are described in code, and the code has to be the real interface.""" + asks = surface(Agent) | surface(Session) | surface(Person) + anchored = { + "turns": "batch", + "sessions": "new", + "budgets": "spent", + "hooks": "hooks", + "board": "board", + "clone": "clone", + "skills": "loads", + "pursue": "pursue", + "tools": "offers", + } + said = {one.name: one.said for one in catalogue()} + for name, member in anchored.items(): + assert member in asks + assert member in said[name], name + # And the ones whose anchor is the vocabulary hmz.flows hands through. + offers = offered() + for name, word in { + "subflows": "load", + "person": "Person", + "state": "flow", + "hooks": "Moment", + "goal": "Goal", + }.items(): + assert word in offers + assert word in said[name], name + + +def test_the_moments_every_backend_reaches_are_everywhere() -> None: + (moments,) = (one for one in catalogue() if one.name == "moments") + assert moments.backends == frozenset() + for one in EVERYWHERE: + assert f"Moment.{one.name}" in moments.said + + +def test_the_briefing_mentions_every_capability_and_its_backends() -> None: + page = briefed() + for one in catalogue(): + assert f"- {one.name}" in page + for backend in one.backends: + assert backend in page + # The split the compiler steers by: what needs declaring is under the second heading. + assert "Every backend:" in page + assert "Only some backends" in page + assert page.index("- turns:") < page.index("Only some backends") + assert page.index("Only some backends") < page.index("- pursue") diff --git a/tests/test_proving.py b/tests/test_proving.py index 7c1178a..19e9e4e 100644 --- a/tests/test_proving.py +++ b/tests/test_proving.py @@ -30,7 +30,9 @@ #: A world that says no quickly: few turns allowed, and a short clock, so a flow that #: cannot end is caught in test time rather than in a minute apiece. -QUICKLY = Scenario("never-done", verdict=False, answer="did some of it", turns=6, seconds=20.0) +QUICKLY = Scenario( + "never-done", verdict=False, answer="did some of it", turns=6, seconds=20.0 +) #: A budget loop in miniature: each stub turn climbs 100k output tokens, so three turns #: spend the 250k this flow holds itself to. From 8bc541047fb650cff42b4c2f19ac15f238367506 Mon Sep 17 00:00:00 2001 From: dongyun Date: Fri, 28 Aug 2026 22:12:16 +0000 Subject: [PATCH 04/18] feat(cli): add hmz check The two readings from a command line: the static one over every file the flow holds, which executes nothing, then the flow loaded in a subprocess held to a clock so its live config model is read too. One finding a line with a count under them, --json for a script, --static to keep the flow unloaded, --strict to hold warnings to the bar. Exit 0 with nothing blocking, 1 with any error -- or any warning under --strict -- and 2 for a line to correct or a name no flow answers to. The readings are reached through Hmz().flows.check, so anything else that checks a flow makes the same call and is refused the same way. Co-Authored-By: Claude Fable 5 --- docs/reference/cli.md | 35 +++++++- docs/reference/sdk.md | 1 + src/hmz/SPEC.md | 22 +++++ src/hmz/cli/__init__.py | 16 ++++ src/hmz/cli/check.py | 119 +++++++++++++++++++++++++++ src/hmz/sdk/SPEC.md | 6 ++ src/hmz/sdk/flows.py | 35 +++++++- tests/test_check_command.py | 156 ++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 1 + 9 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 src/hmz/cli/check.py create mode 100644 tests/test_check_command.py diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7cc3fc8..5186895 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -277,6 +277,39 @@ to a shell on that machine — read [Security](/guide/security). hmz anchor serve --listen 0.0.0.0:7777 --export /srv/project --token "$SECRET" ``` +## `hmz check` + +``` +hmz check [--static] [--strict] [--json] FLOW [FLOW...] +``` + +Reads a flow for what will not run — before anything runs it. Two readings, in their order: a +static one over every file the flow holds, which executes nothing and is safe to point at a +flow nobody has read, and then the flow loaded — in a subprocess held to a clock — so its live +config model is read too. A flow is named the way `-f` names one: `chat`, `official/rlar`, a +path of your own. + +| Argument | | +| --- | --- | +| `--static` | Only the reading that executes nothing: do not load the flow at all. | +| `--strict` | Exit non-zero on warnings too. | +| `--json` | One JSON object per finding, one a line, for a script to read. | + +Each finding prints as `file:line: severity: code: what is wrong`, with a count under them. +An error is a flow that cannot run, cannot be answered or cannot end — a loop nothing inside +can end, a name the interfaces do not answer to, an import of humanize's own internals — and +a warning is a flow that runs and may be regretted: a loop whose only way out is an agent's +verdict, a shaped answer read without a guard, a config that takes anything. + +It exits `0` for flows with nothing blocking (warnings print and pass), `1` where any error +was found — or any warning, under `--strict` — and `2` for a line to correct or a name no +flow answers to. + +```sh +hmz check official/rlar # one warning: a loop only its reviewer ends +hmz check --strict local/mine # hold a flow of your own to the whole bar +``` + ## `hmz flowverses` Where flows come from: a git repository with a `flows/` directory apiece, cloned under @@ -579,7 +612,7 @@ into them. | | | | --- | --- | | `0` | It did what it was asked. | -| `1` | It could not: the target could not be reached, the listener could not be started, there is no such provider, a turn could not be supervised. | +| `1` | It could not: the target could not be reached, the listener could not be started, there is no such provider, a turn could not be supervised, `hmz check` found something blocking. | | `2` | The command line was wrong — argparse's own rejections, a flow that is not there or takes other agents, a malformed listen address, a non-loopback listener with no token. | | `130` | Interrupted. | | *the agent's own* | `hmz anchor` exits with the status of the program it ran, and `hmz providers add` with that of the login it ran. | diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index f83a0a4..a3b649b 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -86,6 +86,7 @@ run.wait(timeout=60) | `find(named)` | The file one flow is written in. Raises `NotAFlow` for a name nothing answers to. | | `about(named)` | The line a flow says about itself. | | `places(named)` | Every agent it needs chosen for it, in the order it takes them. | +| `check(named, static=False)` | Reads it for what will not run, before anything runs it: one finding per thing found, errors and warnings. `static=True` keeps to the reading that executes nothing. | | `configures(named)` | What it can be [set up with](/reference/flows#settings-of-the-flow-s-own), or `None`. | | `resumes(named)` | Whether it says it can be [picked up](/guide/resuming). | | `fork(named, into=None)` | Copies it into this project's own flows, whole. | diff --git a/src/hmz/SPEC.md b/src/hmz/SPEC.md index 9f6f7f7..8d52f4c 100644 --- a/src/hmz/SPEC.md +++ b/src/hmz/SPEC.md @@ -670,6 +670,28 @@ that can happen to a flowverse -- added, fetched again, taken away. that will not go -- MUST say so where it can be read and exit non-zero, and MUST leave the list as it was. None of those MUST reach whoever typed the line as a traceback. +## `hmz check` + +```shell +hmz check [--static] [--strict] [--json] [...] +``` + +Reads a flow for what will not run, before anything runs it. + +- The two readings MUST run in their order: the static one over every file the flow holds, + which MUST NOT import or execute anything of it -- the flow most worth checking is one + nobody has read -- and then the flow loaded and its live config model read. The second MUST + run only in a subprocess with a clock held over it, MUST NOT run where the first found an + error, and `--static` MUST leave it out altogether: a flow that cannot run is not one to + run to find out more about. +- Every finding MUST print one a line -- the file, the line, the severity, the code and what + is wrong -- with a count under them, and `--json` MUST say the same as one JSON object a + line for a script to read. Everything wrong MUST be said at once rather than first-failure + first: a checker is asked so that one reading answers for the whole flow. +- It MUST exit 0 for flows with nothing blocking -- warnings print and pass -- 1 where any + error was found, or any warning under `--strict`, and 2 for a line to correct or a name no + flow answers to, refused as argparse refuses one. + ## `hmz agents` ```shell diff --git a/src/hmz/cli/__init__.py b/src/hmz/cli/__init__.py index 65d758f..2442419 100644 --- a/src/hmz/cli/__init__.py +++ b/src/hmz/cli/__init__.py @@ -160,6 +160,21 @@ def _flowverses(argv: list[str]) -> int: return flowverses(argv) +def _check(argv: list[str]) -> int: + """Reads a flow for what will not run, before anything runs it. + + Args: + argv: What followed the command name. + + Returns: + Zero for a flow with nothing blocking, one for one with something, or two for a + line to correct. + """ + from .check import check + + return check(argv) + + def _cred(argv: list[str]) -> int: """Runs a program whose credentials are kept somewhere other than where it looks. @@ -552,6 +567,7 @@ def _daemon(argv: list[str]) -> int: ), "anchor": (_anchor, "run an agent here that acts on another machine"), "flowverses": (_flowverses, "the places flows come from"), + "check": (_check, "check a flow before anything runs it"), "agents": (_agents, "the agents written down under a name"), "providers": (_providers, "the accounts an agent may be run as"), "fallback": ( diff --git a/src/hmz/cli/check.py b/src/hmz/cli/check.py new file mode 100644 index 0000000..48234c8 --- /dev/null +++ b/src/hmz/cli/check.py @@ -0,0 +1,119 @@ +"""``hmz check`` -- reads a flow for what will not run, before anything runs it. + +Two readings, in their order. The static one is pure `ast` over every file the flow holds: +it executes nothing, so it is safe to point at a flow nobody has read -- generated, fetched, +forked. Then the flow is loaded and its live config model read, in a subprocess held to a +clock, which is what catches what only running the file can show. Both answer with findings, +one a line, so everything wrong is said at once. + +It is here because the moment to check a flow is before something runs it: a CI job holding +a flowverse to its own bar, a flow just written by an agent, a fork about to be tried. The +readings themselves are :mod:`hmz.flows.checking` and :mod:`hmz.flows.proving`, reached +through :class:`hmz.sdk.Hmz` -- the same call anything else that checks a flow makes. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from hmz.flows import Finding + +__all__ = ["check"] + + +def check(argv: list[str]) -> int: + """Carries out one `hmz check` line. + + Args: + argv: What followed the command name. + + Returns: + Zero for flows with nothing blocking -- warnings print and pass, unless `--strict` + says otherwise -- one where any finding blocks, and two for a line to correct or a + name no flow answers to. + """ + import argparse + + parser = argparse.ArgumentParser( + prog="hmz check", + description="Read a flow for what will not run -- before anything runs it: a " + "static reading that executes nothing, then the flow loaded in a subprocess held " + "to a clock. Errors are what no run survives; warnings are runs that may be " + "regretted.", + ) + parser.add_argument( + "flow", + nargs="+", + metavar="FLOW", + help="a flow, by the name `-f` takes -- `chat`, `official/rlar` -- or by a path", + ) + parser.add_argument( + "--static", + action="store_true", + help="only the reading that executes nothing: do not load the flow at all", + ) + parser.add_argument( + "--strict", + action="store_true", + help="exit non-zero on warnings too", + ) + parser.add_argument( + "--json", + action="store_true", + dest="as_json", + help="one JSON object per finding, one a line, for a script to read", + ) + args = parser.parse_args(argv) + + import os + + from hmz.sdk import Hmz + + flows = Hmz().flows + found: list[Finding] = [] + for named in args.flow: + # A name nothing answers to is a line to correct, refused the way argparse refuses + # one -- before anything is read, and for every name on the line at once. + if not os.path.isfile(flows.find(named)): + parser.error(f"no flow called {named!r}") + found.extend(flows.check(named, static=args.static)) + _said(found, as_json=args.as_json, flows=len(args.flow)) + errors = sum(one.severity == "error" for one in found) + warned = len(found) - errors + return 1 if errors or (args.strict and warned) else 0 + + +def _said(found: list[Finding], *, as_json: bool, flows: int) -> None: + """Prints what the readings found, for a person or for a script. + + Args: + found: The findings, in the order they were found. + as_json: Whether to say each as one JSON object a line, and no count under them. + flows: How many flows the line named, for the count. + """ + if as_json: + import json + + for one in found: + print( + json.dumps( + { + "code": one.code, + "severity": one.severity, + "where": str(one.where), + "line": one.line, + "said": one.said, + } + ) + ) + return + from . import many + + for one in found: + print(f"{one.where}:{one.line}: {one.severity}: {one.code}: {one.said}") + errors = sum(one.severity == "error" for one in found) + if found: + print(f"hmz check: {many(errors, 'error')}, {many(len(found) - errors, 'warning')}") + else: + print(f"hmz check: nothing to say about {many(flows, 'flow')}") diff --git a/src/hmz/sdk/SPEC.md b/src/hmz/sdk/SPEC.md index 7b88147..3a7dade 100644 --- a/src/hmz/sdk/SPEC.md +++ b/src/hmz/sdk/SPEC.md @@ -134,6 +134,9 @@ class Flows: def find(self, named: str) -> str: ... def about(self, named: str) -> str: ... def places(self, named: str | os.PathLike[str]) -> tuple[Place, ...]: ... + def check( + self, named: str | os.PathLike[str], *, static: bool = False + ) -> tuple[Finding, ...]: ... def configures(self, named: str | os.PathLike[str]) -> type[BaseModel] | None: ... def resumes(self, named: str | os.PathLike[str]) -> bool: ... def fork(self, named: str, into: str | os.PathLike[str] | None = None) -> str: ... @@ -143,6 +146,9 @@ class Flows: The flows there are, and the places they come from. +- `check` MUST be the two readings of `hmz.flows` in their order -- the static one, then the + flow loaded in a subprocess -- and the second MUST NOT run where the first found an error, + nor say again what the first already said: one call is one answer, whichever way in asked. - Where a flowverse came from MUST be answered here, and MUST be answered from which flowverse it is rather than from whether its URL is empty. Whatever was signed into a URL MUST be taken out of it in one place, which every way of showing one asks. diff --git a/src/hmz/sdk/flows.py b/src/hmz/sdk/flows.py index b4a749e..0fd97c0 100644 --- a/src/hmz/sdk/flows.py +++ b/src/hmz/sdk/flows.py @@ -18,7 +18,7 @@ from pydantic import BaseModel - from hmz.flows import Flowverse, Offer, Place, Running + from hmz.flows import Finding, Flowverse, Offer, Place, Running __all__ = ["Flows", "Flowverses"] @@ -212,6 +212,39 @@ def places(self, named: str | os.PathLike[str]) -> tuple[Place, ...]: return wanted(named) + def check( + self, named: str | os.PathLike[str], *, static: bool = False + ) -> tuple[Finding, ...]: + """Reads a flow for what will not run, before anything runs it. + + Two readings, in their order. The static one is pure `ast` over every file the + flow holds and executes nothing, which is the whole of what `static` keeps. The + second loads the flow and reads its live config model, in a subprocess held to a + clock -- and is left out where the first found an error: a flow that cannot run is + not one to run to find out more about. + + Args: + named: The flow, by the name `-f` takes or by a path. + static: Only the reading that executes nothing. + + Returns: + Every finding, the static reading's first and nothing said twice: a finding the + static reading already made is not repeated off the live model. + """ + from pathlib import Path + + from hmz.flows import ENTRY, checked, find, inside, proved + + at = Path(find(str(named))) + whole = at.parent if at.name == ENTRY else at + found = list(checked(whole)) + if static or any(one.severity == "error" for one in found): + return tuple(found) + proof = proved(whole, name=inside(str(named)), scenarios=()) + said = {one.code for one in found} + found.extend(one for one in proof.findings if one.code not in said) + return tuple(found) + def configures(self, named: str | os.PathLike[str]) -> type[BaseModel] | None: """What a flow can be set up with, or None for one that takes no setting up.""" from hmz.flows import configures diff --git a/tests/test_check_command.py b/tests/test_check_command.py new file mode 100644 index 0000000..c6fbf5a --- /dev/null +++ b/tests/test_check_command.py @@ -0,0 +1,156 @@ +"""``hmz check``, held to its three exit statuses and to printing what a script can read. + +What each reading finds is `test_checking.py`'s and `test_proving.py`'s business; what is +checked here is the command around them -- that a clean flow passes, a blocked one blocks, +`--strict` raises the bar, `--static` keeps the flow unloaded, a name nothing answers to is +a usage error, and `--json` says the same findings a script can parse back. +""" + +from __future__ import annotations + +import json +import textwrap +from typing import TYPE_CHECKING + +import pytest + +from hmz.cli.check import check +from tests.stubs import written + +if TYPE_CHECKING: + from pathlib import Path + +CLEAN = ''' +"""A flow with nothing to say about it.""" + +from hmz.flows import Agent, flow + + +@flow +def run(agents: tuple[Agent], task: str) -> None: + agents[0](task, suppress=True) +''' + +DEAD = ''' +"""A flow whose loop nothing can end.""" + +from hmz.flows import Agent, flow + + +@flow +def run(agents: tuple[Agent], task: str) -> None: + while True: + agents[0](task, suppress=True) +''' + +#: rlar's shape: legal, and one warning -- the loop only its reviewer ends. +WARNED = ''' +"""A flow whose loop waits on its reviewer.""" + +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + + +class Review(BaseModel): + model_config = {"extra": "forbid"} + + done: bool = Field(description="whether it is over") + + +@flow +def run(agents: tuple[Agent], task: str) -> None: + while True: + review = agents[0](task, suppress=True, schema=Review) + if review is not None and review.done: + return +''' + +#: Clean to the static reading, and refused the moment it is loaded. +UNLOADABLE = ''' +"""A flow whose file will not run.""" + +from hmz.flows import Agent, flow + + +@flow +def run(agents: tuple[Agent], task: str) -> None: + agents[0](task, suppress=True) + + +raise RuntimeError("read no further") +''' + + +def test_a_clean_flow_passes_and_says_so( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + at = written(tmp_path, "one", textwrap.dedent(CLEAN)) + assert check([str(at)]) == 0 + assert "nothing to say about 1 flow" in capsys.readouterr().out + + +def test_a_blocking_finding_is_exit_one( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + at = written(tmp_path, "one", textwrap.dedent(DEAD)) + assert check([str(at)]) == 1 + out = capsys.readouterr().out + assert f"{at / '__init__.py'}:" in out + assert "error: dead-loop:" in out + assert "1 error, 0 warnings" in out + + +def test_a_warning_passes_until_strict_raises_the_bar( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + at = written(tmp_path, "one", textwrap.dedent(WARNED)) + assert check([str(at)]) == 0 + assert "warning: unbounded-loop:" in capsys.readouterr().out + assert check(["--strict", str(at)]) == 1 + + +def test_static_keeps_the_flow_unloaded(tmp_path: Path) -> None: + at = written(tmp_path, "one", textwrap.dedent(UNLOADABLE)) + # The static reading has nothing to say about it; loading it is what refuses it. + assert check(["--static", str(at)]) == 0 + assert check([str(at)]) == 1 + + +def test_a_name_nothing_answers_to_is_a_usage_error( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + with pytest.raises(SystemExit) as stopped: + check([str(tmp_path / "nowhere")]) + assert stopped.value.code == 2 + assert "no flow called" in capsys.readouterr().err + + +def test_a_flag_it_does_not_take_is_a_usage_error(tmp_path: Path) -> None: + with pytest.raises(SystemExit) as stopped: + check(["--everything", str(tmp_path)]) + assert stopped.value.code == 2 + + +def test_json_says_the_same_findings_a_script_can_read( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + at = written(tmp_path, "one", textwrap.dedent(DEAD)) + assert check(["--json", "--static", str(at)]) == 1 + lines = capsys.readouterr().out.strip().splitlines() + held = [json.loads(one) for one in lines] # every line parses: no count under them + assert [one["code"] for one in held] == ["dead-loop"] + assert held[0]["severity"] == "error" + assert held[0]["where"].endswith("__init__.py") + assert held[0]["line"] > 0 + assert "cannot end" in held[0]["said"] + + +def test_several_flows_are_one_answer( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + good = written(tmp_path, "good", textwrap.dedent(CLEAN)) + bad = written(tmp_path, "bad", textwrap.dedent(DEAD)) + assert check(["--static", str(good), str(bad)]) == 1 + out = capsys.readouterr().out + assert "dead-loop" in out + assert "1 error, 0 warnings" in out diff --git a/tests/test_cli.py b/tests/test_cli.py index 8bf74b9..4f491dd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -34,6 +34,7 @@ ("trace", set[str]()), ("anchor", {"coganchor"}), ("flowverses", set[str]()), + ("check", set[str]()), ("agents", set[str]()), ] From 779b8c4c3e972682a6fd0da1737665a0aaffe655 Mon Sep 17 00:00:00 2001 From: dongyun Date: Fri, 28 Aug 2026 22:25:24 +0000 Subject: [PATCH 05/18] docs(flows): say how a flow is checked The reference grows a Checking-a-flow section beside Testing-a-flow: the two readings, the rule table with what each code found, the scenarios a proof drives against, and the catalogue. A guide walks the command -- one line, what an error is against a warning, --static for a flow nobody has read, the never-done proof as a library call, --json in a script -- and Writing-a-flow points at it from Check-your-work. Co-Authored-By: Claude Fable 5 --- docs/.vitepress/config.mts | 1 + docs/guide/checking-flows.md | 81 +++++++++++++++++++++++++++++++++++ docs/guide/testing-flows.md | 1 + docs/guide/writing-a-flow.md | 10 ++++- docs/reference/flows.md | 82 ++++++++++++++++++++++++++++++++++++ src/hmz/cli/check.py | 8 ++-- 6 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 docs/guide/checking-flows.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5e9d024..5793ea7 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -223,6 +223,7 @@ export default defineConfig({ { text: 'Settings of its own', link: '/guide/flow-settings' }, { text: 'Many turns at once', link: '/guide/async-flows' }, { text: 'A flow that calls a flow', link: '/guide/calling-flows' }, + { text: 'Checking a flow', link: '/guide/checking-flows' }, { text: 'Testing a flow', link: '/guide/testing-flows' }, { text: 'Flowverses', link: '/guide/flowverses' }, ], diff --git a/docs/guide/checking-flows.md b/docs/guide/checking-flows.md new file mode 100644 index 0000000..484aedc --- /dev/null +++ b/docs/guide/checking-flows.md @@ -0,0 +1,81 @@ +# Checking a flow + +Check a flow before anything runs it: a static reading that executes nothing, then the flow +driven by stubs against a clock. Together they catch the mistakes that otherwise surface +hours into a run — the loop nothing can end, the field read off an answer that failed, the +name the interface does not answer to. + +## One line + +```sh +hmz check official/rlar +``` + +``` +…/rlar/__init__.py:125: warning: unbounded-loop: every way out of this loop waits for an +agent to say so, and an agent may never say it -- give the loop a bound of its own: a budget +read off spent(), a cap on the rounds, a range +hmz check: 0 errors, 1 warning +``` + +A flow is named the way `-f` names one — `chat`, `official/rlar`, a path of your own — and +everything wrong is said at once, one finding a line. The full table of codes is in the +[reference](/reference/flows#checking-a-flow). + +## What an error is, and what a warning is + +An **error** is a flow no run survives: it cannot run, cannot be answered, or cannot end. +`hmz check` exits `1` on any of those. A **warning** is a flow that runs, and a run of it +that may be regretted — rlar's warning above is real and documented: its loop is ended by its +reviewer alone, which is the flow's own shape. Warnings print and pass; `--strict` holds a +flow to the whole bar, which is the right setting for a flowverse's CI: + +```sh +hmz check --strict local/mine +``` + +## The reading that runs nothing + +The static reading is pure `ast` over every file the flow holds. Nothing is imported and +nothing is executed, so it is safe to point at a flow nobody has read — one an agent just +wrote, one fetched off the internet, one about to be forked: + +```sh +hmz check --static somebody-elses/flow +``` + +## The reading that runs it against the worst day + +Without `--static`, the flow is also loaded — in a subprocess held to a clock, never in your +process — and its live config model is read. The same machinery is a library, and the +scenarios are the questions worth asking of a loop: + +```python +from hmz.flows import NEVER_DONE, SILENT, proved + +proof = proved(".humanize/flows/mine", scenarios=(NEVER_DONE, SILENT)) +assert all(one.finished for one in proof.outcomes), proof.outcomes +``` + +`NEVER_DONE` is the reviewer that never says the work is done. The stubs answer every turn at +once — every boolean verdict `False`, every turn adding 100k output tokens to `spent()` — so +a loop held to a budget walks to the end of it in milliseconds, and one whose only exit is +the verdict is caught by the turn cap. That is the executable proof that a run of your flow +can end. `SILENT` answers every turn with nothing, which is what a failed turn answers: a +flow that reads a field off an unguarded answer falls over here rather than at hour three. + +## In a script + +`--json` says the same findings one JSON object a line, and the exit status is the answer: +`0` with nothing blocking, `1` with something, `2` for a line to correct. + +```sh +hmz check --json local/mine | jq -r .code +``` + +## See also + +- [`hmz check`](/reference/cli#hmz-check) — the command and its flags +- [Checking a flow](/reference/flows#checking-a-flow) — the library API and the rule table +- [Testing a flow](/guide/testing-flows) — driving a flow with stand-ins of your own +- [Writing a flow](/guide/writing-a-flow) diff --git a/docs/guide/testing-flows.md b/docs/guide/testing-flows.md index ba1a8c3..20f72b3 100644 --- a/docs/guide/testing-flows.md +++ b/docs/guide/testing-flows.md @@ -174,6 +174,7 @@ uv run pytest --run-agents # also drives the real coding agent CLIs ## See also +- [Checking a flow](/guide/checking-flows) - [Answers in a shape](/guide/shapes) - [config model](/guide/flow-settings) - [hook](/guide/hooks) diff --git a/docs/guide/writing-a-flow.md b/docs/guide/writing-a-flow.md index 404463d..89974f1 100644 --- a/docs/guide/writing-a-flow.md +++ b/docs/guide/writing-a-flow.md @@ -176,7 +176,15 @@ by taking its name. A file whose name starts with `_` is not a flow. ## Check your work -Check which agents the flow declares. +Check the flow itself, before anything runs it: a static reading that executes nothing, then +the flow driven by stubs against a clock — including the world where the reviewer never says +the work is done. See [Checking a flow](/guide/checking-flows). + +```sh +hmz check local/twice +``` + +And check which agents the flow declares. ```python from hmz.flows import drives diff --git a/docs/reference/flows.md b/docs/reference/flows.md index 8ecde32..23e9d39 100644 --- a/docs/reference/flows.md +++ b/docs/reference/flows.md @@ -1130,6 +1130,88 @@ finished. What the turn was doing is left where it got to. A stop that waited for a turn would not read as a stop — a model can think for minutes. +## Checking a flow + +Two readings, before anything runs it — what [`hmz check`](/reference/cli#hmz-check) runs +from a command line, reachable as a library for a test or a loop that writes flows. + +`checked` is the static one: pure `ast` over every file the flow's directory holds (what is +under its `skills/` excepted), executing nothing, so it is safe to point at a flow nobody has +read. It answers with findings rather than raising, one per thing found: + +```python +from hmz.flows import checked + +for one in checked(".humanize/flows/mine"): + print(f"{one.where}:{one.line}: {one.severity}: {one.code}: {one.said}") +``` + +A `Finding` carries `code`, `severity`, `where`, `line` and `said`. An **error** is a flow +that cannot run, cannot be answered, or cannot end — something no run of it survives. A +**warning** is a flow that runs, and a run of it that may be regretted. + +| Code | Severity | What it found | +| --- | --- | --- | +| `unread` | error | A file that will not parse. | +| `not-a-flow` | error | No `__init__.py`, or nothing marked `@flow()`. | +| `unsized-agents` | error | An `agents` annotation that does not state a fixed count. | +| `unread-annotation` | error | The annotation's names live under `TYPE_CHECKING`, where a run cannot read them. | +| `foreign-import` | error | An import of humanize's own modules other than `hmz.flows`. | +| `unknown-name` | error | `from hmz.flows import` a name it does not offer. | +| `unknown-ask` | error | An attribute asked of an agent, session or person that is not on the interfaces. | +| `dead-loop` | error | A constant-true loop with no `break`, `return` or `raise` inside it. | +| `sleeping-loop` | error | A constant-true loop that only sleeps: alive from outside, doing nothing. | +| `stateless-resume` | error | `@flow(resumable=True)` with nowhere to be handed its state. | +| `unbounded-loop` | warning | Every way out of a loop waits on an agent's verdict, and the function holds no bound of its own. | +| `unguarded-answer` | warning | A field read off a suppressed, shaped answer nothing tested against `None`. | +| `unsaid-moment` | warning | A hook hung on a moment only some backends run that no place declares. | +| `loose-config` | warning | A config whose `model_config` neither forbids extras nor freezes. | +| `unsaid-field` | warning | A config field without a `Field(description=...)`. | +| `unsaid-flow` | warning | An entry file without a docstring, so lists of flows show nothing for it. | +| `state-kept` | warning | Kept state something writes and nothing ever clears. | +| `twice-named` | warning | Two flows in one file under one name; the first wins. | + +Every rule is the proof of an absence, worked out one function at a time — no exit in this +loop, no bound in this function, no guard on this name. Nothing claims an exit reachable or +follows a value through a call: a flow that keeps its loop in one function and its bound in +another is a flow the reading trusts. + +`proved` is the second reading: the flow loaded and driven for real, in a subprocess per +scenario, by stubs that claim every capability over the real driver base classes — so the +hooks it hangs fire as they would, every turn lands at once, and each adds what the scenario +says to `spent()`. The parent holds the clock, sleeps are free, and the flow works in a +scratch directory taken away with the process. + +```python +from hmz.flows import ALWAYS_DONE, NEVER_DONE, SILENT, proved + +proof = proved(".humanize/flows/mine", scenarios=(NEVER_DONE, ALWAYS_DONE, SILENT)) +assert proof.findings == () +assert all(one.finished for one in proof.outcomes), proof.outcomes +``` + +A `Scenario` says how the world answers: every boolean field of a shaped answer says its +`verdict`, every string field its `answer`, each turn climbs `climb` output tokens, and the +proof ends at `turns` turns or `seconds` seconds, whichever the flow forces. Three are named: +`NEVER_DONE` is the reviewer that never says the work is done — a loop with a bound of its +own still ends here, which is the executable proof that a run can end; `ALWAYS_DONE` is the +shortest road through; `SILENT` answers every turn with nothing, which is what a failed turn +answers, so it is every guard tried at once. A flow the loading refuses comes back as a +`refused-load` finding, and the flow's live config model is read against the config rules +whether or not the static reading could see it. + +And the catalogue, for whoever — or whatever — is writing a flow against this installation: + +```python +from hmz.flows import briefed, catalogue + +catalogue() # one Capability per thing a flow may build on, with the backends that serve it +briefed() # the same, rendered as one page to steer by +``` + +Read off the live interface at call time — the moments off the enum, the backend sets off +the driver classes' own declarations — so what it promises is what this installation serves. + ## Testing a flow A flow is a function, so drive it with something that is not a coding agent: diff --git a/src/hmz/cli/check.py b/src/hmz/cli/check.py index 48234c8..029fa4e 100644 --- a/src/hmz/cli/check.py +++ b/src/hmz/cli/check.py @@ -66,7 +66,7 @@ def check(argv: list[str]) -> int: ) args = parser.parse_args(argv) - import os + from pathlib import Path from hmz.sdk import Hmz @@ -75,7 +75,7 @@ def check(argv: list[str]) -> int: for named in args.flow: # A name nothing answers to is a line to correct, refused the way argparse refuses # one -- before anything is read, and for every name on the line at once. - if not os.path.isfile(flows.find(named)): + if not Path(flows.find(named)).is_file(): parser.error(f"no flow called {named!r}") found.extend(flows.check(named, static=args.static)) _said(found, as_json=args.as_json, flows=len(args.flow)) @@ -114,6 +114,8 @@ def _said(found: list[Finding], *, as_json: bool, flows: int) -> None: print(f"{one.where}:{one.line}: {one.severity}: {one.code}: {one.said}") errors = sum(one.severity == "error" for one in found) if found: - print(f"hmz check: {many(errors, 'error')}, {many(len(found) - errors, 'warning')}") + print( + f"hmz check: {many(errors, 'error')}, {many(len(found) - errors, 'warning')}" + ) else: print(f"hmz check: nothing to say about {many(flows, 'flow')}") From a08617730802cf83981cb8781fd4e0e179e04147 Mon Sep 17 00:00:00 2001 From: dongyun Date: Fri, 28 Aug 2026 22:28:23 +0000 Subject: [PATCH 06/18] feat(flows): offer MINE, so a flow that writes flows knows where yours live The aot flow lands what it compiles in the flows of your own, and the two places those are is a fact written in verses.py -- handed through rather than spelled again where it would drift. Co-Authored-By: Claude Fable 5 --- src/hmz/flows/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hmz/flows/__init__.py b/src/hmz/flows/__init__.py index ecf8627..66fd277 100644 --- a/src/hmz/flows/__init__.py +++ b/src/hmz/flows/__init__.py @@ -119,6 +119,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "EVERYWHERE", "FLOWS", "LOCAL", + "MINE", "NEVER_DONE", "OFFICIAL", "PERMISSIONS", From 73e4c0a6959ecca2e4ac24058724e6bed3b51b4d Mon Sep 17 00:00:00 2001 From: dongyun Date: Sat, 29 Aug 2026 00:47:30 +0000 Subject: [PATCH 07/18] fix(flows): fabricate a shaped answer to a list's own lower bound A field that takes at least three answers was answered with none: the fabrication gave every list [], the model refused it, and a flow that bounds its own retries read as one that could not be driven at all. Now a list is answered with as many fabricated elements as its bound asks, and an Annotated element is answered as the type it annotates. Found scanning the official flows: parallel_flame_chase's coordinator takes a plan of at least three lanes. Its own cross-field validator is still past what deterministic fabrication can satisfy -- which the proof reports honestly, as a flow that fails closed after three tries. Co-Authored-By: Claude Fable 5 --- src/hmz/flows/proving.py | 13 ++++++++++++- tests/test_proving.py | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/hmz/flows/proving.py b/src/hmz/flows/proving.py index b2cc5d5..e297a09 100644 --- a/src/hmz/flows/proving.py +++ b/src/hmz/flows/proving.py @@ -593,8 +593,13 @@ def _filled(kind: Any, field: Any, scenario: Scenario) -> Any: Returns: The value. """ + from typing import Annotated + from pydantic import BaseModel + if get_origin(kind) is Annotated: + # The constraints ride along in the field itself; what is answered is the type. + return _filled(get_args(kind)[0], field, scenario) for said in _unioned(kind): if said is bool: return scenario.verdict @@ -608,7 +613,13 @@ def _filled(kind: Any, field: Any, scenario: Scenario) -> Any: if said in (int, float): return 0 if get_origin(said) in (list, tuple, set, frozenset): - return [] + # As many as the field says it takes at the least, each made the same way: + # a shape that requires three lanes is answered with three, not refused. + fewest = 0 + for bound in getattr(field, "metadata", None) or (): + fewest = max(fewest, getattr(bound, "min_length", 0) or 0) + inner = next(iter(get_args(said)), None) + return [_filled(inner, None, scenario) for _ in range(fewest)] if get_origin(said) is dict: return {} if isinstance(said, type) and issubclass(said, BaseModel): diff --git a/tests/test_proving.py b/tests/test_proving.py index 19e9e4e..3e9bf66 100644 --- a/tests/test_proving.py +++ b/tests/test_proving.py @@ -382,3 +382,19 @@ class Impossible(BaseModel): assert _said(Shaped, SILENT) == "" assert _said(None, SILENT) == "" assert _said(None, ALWAYS_DONE) == ALWAYS_DONE.answer + + +def test_a_list_that_takes_at_least_some_is_answered_with_that_many() -> None: + from typing import Annotated + + class Planned(BaseModel): + lanes: list[Nested] = Field(min_length=3) + tags: list[Annotated[str, Field(min_length=1)]] = Field(min_length=1) + loose: list[str] + + made = _made(Planned, NEVER_DONE) + held = Planned.model_validate(made) + assert len(held.lanes) == 3 + assert held.lanes[0] == Nested(said=NEVER_DONE.answer, fine=False) + assert held.tags == [NEVER_DONE.answer] + assert held.loose == [] From 2ffd55c05c9d293c8b959b72dc702b64c301c248 Mon Sep 17 00:00:00 2001 From: dongyun Date: Sat, 29 Aug 2026 01:17:51 +0000 Subject: [PATCH 08/18] feat(flows): add unknown-verdict -- a comparison no answer can satisfy A shaped answer's Literal field compared against a value the shape does not offer -- review.verdict == "DONE" over Literal["done", "redo"] -- is a guard that never opens, or one that never shuts. Both readings pass, both provings pass, and the flow silently steers by a value no answer will ever hold; found where the shape is declared in the same file and left be where it is not. The rule oh-my-humanize's freeze checker runs on its edge conditions (references undeclared verdict), carried over to answers held to a shape. Co-Authored-By: Claude Fable 5 --- docs/reference/flows.md | 1 + src/hmz/flows/checking.py | 164 ++++++++++++++++++++++++++++++++++++++ tests/test_checking.py | 103 ++++++++++++++++++++++++ 3 files changed, 268 insertions(+) diff --git a/docs/reference/flows.md b/docs/reference/flows.md index 23e9d39..9dc1e9f 100644 --- a/docs/reference/flows.md +++ b/docs/reference/flows.md @@ -1164,6 +1164,7 @@ that cannot run, cannot be answered, or cannot end — something no run of it su | `stateless-resume` | error | `@flow(resumable=True)` with nowhere to be handed its state. | | `unbounded-loop` | warning | Every way out of a loop waits on an agent's verdict, and the function holds no bound of its own. | | `unguarded-answer` | warning | A field read off a suppressed, shaped answer nothing tested against `None`. | +| `unknown-verdict` | warning | An answer's field compared against a value its shape does not offer, so the comparison can never be what it reads as. | | `unsaid-moment` | warning | A hook hung on a moment only some backends run that no place declares. | | `loose-config` | warning | A config whose `model_config` neither forbids extras nor freezes. | | `unsaid-field` | warning | A config field without a `Field(description=...)`. | diff --git a/src/hmz/flows/checking.py b/src/hmz/flows/checking.py index 85491e9..27431bb 100644 --- a/src/hmz/flows/checking.py +++ b/src/hmz/flows/checking.py @@ -381,6 +381,13 @@ def _root(node: ast.expr) -> str: return node.id if isinstance(node, ast.Name) else "" +def _tip(node: ast.expr) -> str: + """The name at the tip of one dotted expression -- `Literal` of `typing.Literal`.""" + if isinstance(node, ast.Attribute): + return node.attr + return node.id if isinstance(node, ast.Name) else "" + + def _moments_in(annotation: ast.expr, read: _Read) -> set[str]: """Every moment one annotation names, which is a place saying what it needs.""" return { @@ -797,6 +804,9 @@ class _Answer(NamedTuple): shaped: bool suppressed: bool line: int + #: The name of the shape the turn was held to, where it was named plainly -- "" for a + #: turn held to no shape, or to one written some way this reading does not follow. + model: str = "" @dataclass @@ -1211,6 +1221,7 @@ def _called(value: ast.Call, scope: _Scope) -> frozenset[str] | _Crew | _Answer for one in value.keywords ), line=value.lineno, + model=_shaped_as(value), ) if isinstance(func, ast.Attribute) and func.attr in {"aturn", "pursue", "apursue"}: asked = _valued(func.value, scope) @@ -1224,10 +1235,19 @@ def _called(value: ast.Call, scope: _Scope) -> frozenset[str] | _Crew | _Answer for one in value.keywords ), line=value.lineno, + model=_shaped_as(value), ) return None +def _shaped_as(value: ast.Call) -> str: + """The name of the shape one turn is held to, where the call names it plainly.""" + for keyword in value.keywords: + if keyword.arg == "schema" and isinstance(keyword.value, ast.Name): + return keyword.value.id + return "" + + #: The comparisons that read as a bound: a count against something, ordered. _ORDERED = (ast.Lt, ast.LtE, ast.Gt, ast.GtE) @@ -1260,6 +1280,7 @@ def _expression(node: ast.expr, scope: _Scope) -> None: for side in [node.left, *node.comparators] ): scope.bounded = True + _never(node, scope) _guards(node, scope) for one in [node.left, *node.comparators]: _expression(one, scope) @@ -1389,6 +1410,149 @@ def _asked(node: ast.Attribute, scope: _Scope) -> None: ) +#: The comparisons that ask whether a value is exactly one of some others. +_BY_VALUE = (ast.Eq, ast.NotEq, ast.In, ast.NotIn) + + +def _never(node: ast.Compare, scope: _Scope) -> None: + """One comparison read against the shape behind it, for a value no answer holds. + + `review.verdict == "DONE"` over `Literal["done", "redo"]` is a guard that never opens, + and `!= "DONE"` one that never shuts: either way the flow steers by a value the shape + cannot answer with. Said only where everything is certain -- the answer's shape is a + model this same file declares, the field spells its values out, and the other side is + constants -- so a shape read from elsewhere is let be rather than guessed at. + """ + if len(node.ops) != 1 or not isinstance(node.ops[0], _BY_VALUE): + return + membership = isinstance(node.ops[0], (ast.In, ast.NotIn)) + pairs = [(node.left, node.comparators[0])] + if not membership: + # `"done" == review.verdict` reads the same either way round; `"d" in x` does not. + pairs.append((node.comparators[0], node.left)) + for asked, against in pairs: + if not (isinstance(asked, ast.Attribute) and isinstance(asked.value, ast.Name)): + continue + answer = scope.answers.get(asked.value.id) + if answer is None or not answer.model: + continue + held = _offered(answer.model, scope.read, asked.attr, set()) + values = _values_of(against, membership=membership) + if held is None or values is None: + return + offers = ", ".join(repr(one) for one in sorted(held, key=repr)) + for value in values: + if value not in held: + scope.findings.append( + Finding( + "unknown-verdict", + "warning", + scope.read.where, + node.lineno, + f"no answer holds {value!r} at {asked.attr!r} -- the shape " + f"offers {offers}, and a comparison against a value it cannot " + "hold reads as a guard and guards nothing", + ) + ) + return + + +def _offered( + model: str, read: _Read, field_name: str, seen: set[str] +) -> frozenset[object] | None: + """Every value one field of a model may hold, read off the model's own words. + + Follows local bases the way the strictness rule does -- a field a model inherits is as + much its shape as one it declares. + + Args: + model: The model's name. + read: The file, whose models are the only ones read. + field_name: The field asked about. + seen: The models already walked, which stops a circular inheritance. + + Returns: + The values, or None where the model or the field is not here, or the field's + annotation does not spell its values out. + """ + if model in seen or model not in read.models: + return None + seen.add(model) + declared = read.models[model] + for node in declared.body: + if ( + isinstance(node, ast.AnnAssign) + and isinstance(node.target, ast.Name) + and node.target.id == field_name + ): + return _options_in(node.annotation) + for base in declared.bases: + if isinstance(base, ast.Name): + held = _offered(base.id, read, field_name, seen) + if held is not None: + return held + return None + + +def _options_in(annotation: ast.expr | None) -> frozenset[object] | None: + """Every value one annotation admits, where it spells them all out. + + `Literal` through and through -- unions, `Optional` and `Annotated` read through -- + or None for anything open: a field that may hold a plain `str` is a field any + comparison against is an honest one. + """ + said = _unquoted(annotation) + if said is None: + return None + if isinstance(said, ast.Constant) and said.value is None: + return frozenset({None}) + if isinstance(said, ast.BinOp) and isinstance(said.op, ast.BitOr): + left = _options_in(said.left) + right = _options_in(said.right) + if left is None or right is None: + return None + return left | right + if not isinstance(said, ast.Subscript): + return None + head = _tip(said.value) + parts = _elements(said.slice) + if head == "Literal": + options: set[object] = set() + for part in parts: + if not isinstance(part, ast.Constant): + return None + options.add(part.value) + return frozenset(options) + if head == "Annotated" and parts: + return _options_in(parts[0]) + if head == "Optional" and parts: + inner = _options_in(parts[0]) + return None if inner is None else inner | {None} + if head == "Union": + gathered: frozenset[object] = frozenset() + for part in parts: + inner = _options_in(part) + if inner is None: + return None + gathered |= inner + return gathered + return None + + +def _values_of(node: ast.expr, *, membership: bool) -> list[object] | None: + """The constant values one side of a comparison holds, or None where it is not sure.""" + if not membership: + return [node.value] if isinstance(node, ast.Constant) else None + if isinstance(node, (ast.Tuple, ast.List, ast.Set)): + values: list[object] = [] + for elt in node.elts: + if not isinstance(elt, ast.Constant): + return None + values.append(elt.value) + return values + return None + + # --------------------------------------------------------------------------------------- # The loop rules: a loop is legal when something inside it can end it. # --------------------------------------------------------------------------------------- diff --git a/tests/test_checking.py b/tests/test_checking.py index 7fe6da7..02b4f3d 100644 --- a/tests/test_checking.py +++ b/tests/test_checking.py @@ -36,6 +36,7 @@ "stateless-resume": "error", "unbounded-loop": "warning", "unguarded-answer": "warning", + "unknown-verdict": "warning", "unsaid-moment": "warning", "loose-config": "warning", "unsaid-field": "warning", @@ -488,6 +489,108 @@ def run(agents: tuple[Agent], task: str) -> None: pytest.param( DOC + """ +from typing import Literal + +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + +class Review(BaseModel): + model_config = {"extra": "forbid"} + + verdict: Literal["done", "redo"] = Field(description="how the round went") + +@flow +def run(agents: tuple[Agent], task: str) -> None: + review = agents[0](task, suppress=True, schema=Review) + if review and review.verdict == "DONE": + return +""", + {"unknown-verdict"}, + id="unknown-verdict", + ), + pytest.param( + DOC + + """ +from typing import Literal + +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + +class Review(BaseModel): + model_config = {"extra": "forbid"} + + verdict: Literal["done", "redo"] = Field(description="how the round went") + +@flow +def run(agents: tuple[Agent], task: str) -> None: + review = agents[0](task, suppress=True, schema=Review) + if review and review.verdict in ("done", "OOPS"): + return +""", + {"unknown-verdict"}, + id="unknown-verdict-a-dead-member", + ), + pytest.param( + DOC + + """ +from typing import Literal + +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + +class Review(BaseModel): + model_config = {"extra": "forbid"} + + verdict: Literal["done", "redo"] = Field(description="how the round went") + +@flow +def run(agents: tuple[Agent], task: str) -> None: + review = agents[0](task, suppress=True, schema=Review) + if review and review.verdict == "done": + return +""", + CLEAN, + id="unknown-verdict-edge-a-value-the-shape-offers", + ), + pytest.param( + DOC + + """ +from hmz.flows import Agent, flow +from pydantic import BaseModel, Field + +class Review(BaseModel): + model_config = {"extra": "forbid"} + + verdict: str = Field(description="how the round went, in its own words") + +@flow +def run(agents: tuple[Agent], task: str) -> None: + review = agents[0](task, suppress=True, schema=Review) + if review and review.verdict == "DONE": + return +""", + CLEAN, + id="unknown-verdict-edge-a-field-left-open", + ), + pytest.param( + DOC + + """ +from _shapes import Review + +from hmz.flows import Agent, flow + +@flow +def run(agents: tuple[Agent], task: str) -> None: + review = agents[0](task, suppress=True, schema=Review) + if review and review.verdict == "DONE": + return +""", + CLEAN, + id="unknown-verdict-edge-a-shape-declared-elsewhere", + ), + pytest.param( + DOC + + """ from hmz.flows import Agent, Moment, flow @flow From 04d616b45b30514cba67e2e0e9ee26a0e2a50182 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:16:12 +0000 Subject: [PATCH 09/18] feat(flows): add the atlas -- a flow compiled to a graph before it runs An atlas is a flow whose body is read rather than run. `atlas.py` is the vocabulary: the marks (`atlas`, `mind`, `logic`, `sub`) and the prophecy a body compiles to. `prophesying.py` is the reading, pure `ast` over the flow's files, sharing `checking.py`'s parsing and rules and adding the ones an atlas is stricter for -- every one of them decidable, since the subset is chosen so there is nothing to guess. `stepping.py` walks a prophecy a node at a time and picks a stopped run up from the answers it wrote down. Two kinds of ordinary node and one that is a graph: a `mind` is a turn and has one way out, a `logic` is Python and may have several, and an atlas called by an atlas is a supernode. What flows between them is a pydantic model or a plain kind, checked edge by edge before anything runs. `checking.checked` hands an atlas to the stricter reading, so `hmz check` asks one question either way; `driving.declares` hands back the walk rather than the entry point, so every way of running a flow gets the compiling for free. Co-Authored-By: Claude Opus 5 (1M context) --- src/hmz/cli/check.py | 53 +- src/hmz/flows/__init__.py | 63 ++ src/hmz/flows/atlas.py | 697 ++++++++++++++++ src/hmz/flows/checking.py | 264 +++++- src/hmz/flows/driving.py | 36 +- src/hmz/flows/prophesying.py | 1469 ++++++++++++++++++++++++++++++++++ src/hmz/flows/stepping.py | 464 +++++++++++ src/hmz/sdk/flows.py | 54 +- tests/test_prophesying.py | 453 +++++++++++ tests/test_stepping.py | 419 ++++++++++ 10 files changed, 3929 insertions(+), 43 deletions(-) create mode 100644 src/hmz/flows/atlas.py create mode 100644 src/hmz/flows/prophesying.py create mode 100644 src/hmz/flows/stepping.py create mode 100644 tests/test_prophesying.py create mode 100644 tests/test_stepping.py diff --git a/src/hmz/cli/check.py b/src/hmz/cli/check.py index 029fa4e..15d5b25 100644 --- a/src/hmz/cli/check.py +++ b/src/hmz/cli/check.py @@ -64,6 +64,18 @@ def check(argv: list[str]) -> int: dest="as_json", help="one JSON object per finding, one a line, for a script to read", ) + said = parser.add_mutually_exclusive_group() + said.add_argument( + "--prophecy", + action="store_true", + help="print what each atlas compiles to instead of what is wrong with it", + ) + said.add_argument( + "--ship", + action="store_true", + help="write each atlas's prophecy into its own directory, for runs to walk " + "instead of compiling it again", + ) args = parser.parse_args(argv) from pathlib import Path @@ -71,12 +83,15 @@ def check(argv: list[str]) -> int: from hmz.sdk import Hmz flows = Hmz().flows - found: list[Finding] = [] for named in args.flow: # A name nothing answers to is a line to correct, refused the way argparse refuses # one -- before anything is read, and for every name on the line at once. if not Path(flows.find(named)).is_file(): parser.error(f"no flow called {named!r}") + if args.prophecy or args.ship: + return _foretold(args.flow, ship=args.ship) + found: list[Finding] = [] + for named in args.flow: found.extend(flows.check(named, static=args.static)) _said(found, as_json=args.as_json, flows=len(args.flow)) errors = sum(one.severity == "error" for one in found) @@ -84,6 +99,42 @@ def check(argv: list[str]) -> int: return 1 if errors or (args.strict and warned) else 0 +def _foretold(flows: list[str], *, ship: bool) -> int: + """Prints or writes what each atlas on the line compiles to. + + Args: + flows: The flows, by the names the line gave. + ship: Whether to write each prophecy into its flow's own directory rather than print + it. + + Returns: + Zero where every one of them compiled, and one where any did not: a name that is not + an atlas, or is one that the reading refused. + """ + from hmz.flows import NotAFlow, canonical + from hmz.sdk import Hmz + + held = Hmz().flows + worst = 0 + for named in flows: + if ship: + try: + print(f"{named}: {held.foretell(named)}") + except NotAFlow as why: + print(f"hmz check: {why}") + worst = 1 + continue + prophecy = held.prophecy(named) + if prophecy is None: + print( + f"hmz check: {named} is not an atlas that compiles -- drop --prophecy" + ) + worst = 1 + continue + print(canonical(prophecy)) + return worst + + def _said(found: list[Finding], *, as_json: bool, flows: int) -> None: """Prints what the readings found, for a person or for a script. diff --git a/src/hmz/flows/__init__.py b/src/hmz/flows/__init__.py index 3699aa8..244646b 100644 --- a/src/hmz/flows/__init__.py +++ b/src/hmz/flows/__init__.py @@ -52,6 +52,20 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: from typing import TYPE_CHECKING, Any, NamedTuple, overload from .agent import Agent, Driven, Person, Session +from .atlas import ( + Edge, + Node, + Prophecy, + Shape, + atlas, + canonical, + digest, + kept, + logic, + mind, + sub, + told, +) from .checking import Capability, Finding, briefed, catalogue, checked from .driving import ( NotAFlow, @@ -66,6 +80,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: running, wanted, ) +from .prophesying import Prophesied, prophesied from .proving import ALWAYS_DONE, NEVER_DONE, SILENT, Outcome, Proof, Scenario, proved from .verses import ( BUILTIN, @@ -127,6 +142,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "NEVER_DONE", "OFFICIAL", "PERMISSIONS", + "PROPHECY", "SILENT", "SWARM", "USER", @@ -137,6 +153,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "Board", "Capability", "Driven", + "Edge", "Event", "Failed", "Finding", @@ -151,6 +168,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "Item", "Model", "Moment", + "Node", "NotAFlow", "Occasion", "Offer", @@ -159,12 +177,15 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "Place", "Profile", "Proof", + "Prophecy", + "Prophesied", "Question", "Refused", "Remote", "Running", "Scenario", "Session", + "Shape", "Stopped", "Tool", "Unhooked", @@ -173,33 +194,43 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "Verdict", "about", "at", + "atlas", "backends", "briefed", + "canonical", "carries", "catalogue", "checked", "configures", "container", + "digest", "drives", "entry", "find", "flow", "flowverses", + "foretold", "fork", "found", "held", "holds", "home", "inside", + "kept", "load", "loaded", + "logic", + "mind", "models", "nearest", "offered", "offers", + "prophesied", "proved", "resumes", "running", + "sub", + "told", "wanted", ] @@ -283,6 +314,12 @@ def __getattr__(name: str) -> object: #: imports and the `skills/` it brings, so the entry point is named rather than guessed. ENTRY = "__init__.py" +#: And what an atlas's directory may hold the prophecy it was already compiled to in. A +#: flowverse that ships one ships the graph its flow was checked into, and that graph is +#: what runs: the compiling is where an atlas is refused, and a repository which has been +#: through it once has an answer worth carrying rather than working out again. +PROPHECY = "prophecy.pkl" + #: What a flow's own name is separated from the one inside it by. A flow that holds one flow #: is named by itself; one that holds three names each of them after it. _INSIDE = ":" @@ -759,6 +796,32 @@ def find(named_: str) -> str: return at_ +def foretold(named_: str) -> str: + """Where the prophecy one flow ships is, for a flow that ships one. + + An atlas is compiled before it runs, and a flowverse may ship what compiling it came + to: `prophecy.pkl`, beside the entry point, holding the graph the atlas was read into. + Where there is one it is what runs -- the compiling having already happened, in the + repository the flow came from, over the source that repository holds. + + What is beside it still matters. A prophecy names the functions its nodes are, and + those are in the flow's own Python: a directory holding a prophecy and no entry point + is not a flow, the same way a directory holding neither is not one. + + Args: + named_: A flow's name, as :func:`find` takes it. + + Returns: + The path to it, and "" for a flow that ships none -- which is every flow that is not + an atlas, and most atlases. + """ + beside = at(named_) + if not beside: + return "" + held = os.path.join(beside, PROPHECY) + return held if os.path.isfile(held) else "" + + def at(named_: str) -> str: """The flow's own directory, which is where what it brings with it lives. diff --git a/src/hmz/flows/atlas.py b/src/hmz/flows/atlas.py new file mode 100644 index 0000000..8b37e6d --- /dev/null +++ b/src/hmz/flows/atlas.py @@ -0,0 +1,697 @@ +"""What an atlas is written in, and the prophecy it compiles to. + +A flow is a Python file that may branch any way it likes, and the one thing nothing can ask +it is what it is about to do. An atlas is the other bargain: a narrower Python, whose entry +point is read rather than run, and whose shape is therefore a graph that exists before +anything does. This is the vocabulary of both halves -- the marks an atlas is written with, +and the prophecy it is compiled to. + +An atlas is a flow. It is marked, found, named, listed and run the way every other flow is, +so nothing that already knows what a flow is has to learn a second thing:: + + from hmz.flows import Agent, atlas, logic, mind + from pydantic import BaseModel + + class Agents(NamedTuple): + writer: Agent + reviewer: Agent + + class Draft(BaseModel): + model_config = {"extra": "forbid"} + text: str + + class Verdict(BaseModel): + model_config = {"extra": "forbid"} + done: bool + + @mind + def write(agent: Agent, task: str) -> Draft: ... + + @logic + def judge(said: Draft) -> Verdict: ... + + @atlas + def run(agents: Agents, task: str) -> None: + draft = write(agents.writer, task) + verdict = judge(draft) + while not verdict.done: + draft = write(agents.writer, task) + +There are two kinds of ordinary node and one kind that is a whole flow. A `mind` is a turn: +real work by a real agent, handed the agent the call site names. A `logic` is a Python +function: no agent, no turn, and a decision anything can read. An atlas called by another +atlas is a supernode -- one node from outside, one prophecy from within. + +A mind has one way out and a logic may have several. That is the whole of why the two are +told apart: a branch is a decision, and a decision nothing but a model made is a decision no +reading of the flow can state. So the node a branch hangs off is a logic node, and what a +model said reaches a branch by being read by one. + +A prophecy is canonical: the same atlas written twice the same way compiles to the same text, +byte for byte, and :func:`digest` over that text is what a run picked up again checks itself +against. An atlas rewritten between two runs is a different prophecy, and a run that carried on +into it would be a run resuming into somewhere it had never been. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, NamedTuple, overload + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + +__all__ = [ + "AGENTS", + "CONFIG", + "INPUT", + "Atlas", + "Edge", + "Field", + "Marked", + "Node", + "Prophecy", + "Reads", + "Shape", + "Sub", + "When", + "atlas", + "canonical", + "digest", + "kept", + "logic", + "mind", + "sub", + "told", +] + +#: What a node reads when it is handed one of the run's agents rather than a value: the +#: agents are what the run was started with rather than anything a node answered, so they +#: are named where a node id would be. Not an identifier, so nothing an atlas can write +#: collides with it. +AGENTS = "@agents" + +#: And what it reads when it is handed the flow's own input -- the task a command line gave, +#: or the shape a supernode was called with. +INPUT = "@input" + +#: And what it reads when it is handed what the run was set up with, for an atlas that says +#: it takes a config. +CONFIG = "@config" + +#: What a node is: a turn taken by an agent, a Python function, or a whole atlas of its own. +#: The first two are what a prophecy is made of, and the third is what one prophecy is made of +#: another by, which is what a supernode is. +type Kind = Literal["mind", "logic", "atlas"] + +#: Where a marked node keeps what its mark said, and where an atlas keeps that it is one. On +#: the function rather than in a table, for the reason `flow` puts it there: a file is read +#: by running it, and a mark that travels with the thing it describes is a mark there is only +#: one place to look for. +MARKED = "__humanize_node__" +ATLAS = "__humanize_atlas__" + + +@dataclass(frozen=True, slots=True) +class Atlas: + """That a function is an atlas, and what the mark said beyond what `Flow` holds. + + An atlas carries this as well as the :class:`~hmz.flows.Flow` every flow carries, so that + everything which already reads flows goes on reading this one, and only what compiles it + has to know the difference. + + Attributes: + name: What it is called inside its own file, which is the half after the colon, and "" + for the one the file holds under its own name. The same name `flow` takes, and the + same name a supernode of another file is reached by. + """ + + name: str = "" + + +@dataclass(frozen=True, slots=True) +class Marked: + """What `mind` or `logic` marked a function with. + + Attributes: + kind: Which of the two it is. A mind takes a turn and has one way out; a logic is + Python and may have several. + rerun: Whether a run picked up again runs this node again where the last run was + stopped inside it. True is what a node says by saying nothing: work that was cut off + partway is work that was not done. False is for a node that has had its effect by + the time it can be interrupted -- and such a node answers with nothing, since a run + stepping past it has no answer of its to carry on with. + """ + + kind: Kind + rerun: bool = True + + +@dataclass(frozen=True, slots=True) +class Sub: + """An atlas of another file, as the atlas reaching for it names it. + + Bound at the top of a file and called in a body, which is the one way an atlas reaches a + flow that is not beside it:: + + review = sub("official/review") + + Never called: an atlas's body is read rather than run, and what runs is the prophecy the + reading compiled. Calling one is therefore an atlas being run some way this module knows + nothing about, and says so rather than doing something surprising. + + Attributes: + named: The flow, by the name `-f` takes. + """ + + named: str + + def __call__(self, *args: object, **kwargs: object) -> object: # noqa: ARG002 + """Refuses: an atlas's body is compiled, and the prophecy is what runs. + + Args: + args: Whatever the call was written with, which is read where it is written. + kwargs: The same. + + Raises: + TypeError: Always. A supernode is run by the prophecy around it, and a body that ran + would be an atlas being run as though it were an ordinary flow. + """ + raise TypeError( + f"{self.named} is a supernode: an atlas's body is compiled rather than run, " + "so nothing calls this outside the prophecy it was read into" + ) + + +def sub(named: str) -> Sub: + """Names the atlas one supernode is, for a body to call it by. + + The counterpart of :func:`~hmz.flows.load`, and the only one an atlas has: `load` answers + with a flow that may be anything, and an atlas that called one would be a prophecy with a + hole where a node should be. So an atlas reaches another atlas, by the name `-f` takes, + and reaches nothing else. + + Args: + named: The flow, by the name `-f` takes -- `official/review`, `local/triage:pass`. + + Returns: + Something for a body to call, which nothing ever calls: it is read where it is written, + and the atlas it names is compiled into the prophecy reading it. + """ + return Sub(named) + + +@overload +def mind[**P, T](call: Callable[P, T], /) -> Callable[P, T]: ... + + +@overload +def mind[**P, T]( + *, rerun: bool = True +) -> Callable[[Callable[P, T]], Callable[P, T]]: ... + + +def mind[**P, T]( + call: Callable[P, T] | None = None, /, *, rerun: bool = True +) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]: + """Marks a function as a node an agent takes a turn in -- the work itself. + + A mind is handed the agent the call site named and whatever else flows into it, and + answers with a shape:: + + @mind + def write(agent: Agent, task: str) -> Draft: + return agent(f"draft this: {task}", shape=Draft) + + It has exactly one way out. What a model said is not a decision until something read it, + so a branch is hung off a logic node and never off this: a prophecy that branched on a + turn would be a prophecy whose shape is whatever the model happened to say. + + Args: + call: The function, when the mark is written with no arguments at all. + rerun: Whether a run picked up again runs this node again where the last one stopped + inside it, which is what a node says by saying nothing. + + Returns: + The function, unchanged but for what it now says about itself. + """ + + def marks(said: Callable[P, T]) -> Callable[P, T]: + setattr(said, MARKED, Marked("mind", rerun=rerun)) + return said + + return marks if call is None else marks(call) + + +@overload +def logic[**P, T](call: Callable[P, T], /) -> Callable[P, T]: ... + + +@overload +def logic[**P, T]( + *, rerun: bool = True +) -> Callable[[Callable[P, T]], Callable[P, T]]: ... + + +def logic[**P, T]( + call: Callable[P, T] | None = None, /, *, rerun: bool = True +) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]: + """Marks a function as a node that is Python -- the deciding, the counting, the shaping. + + A logic drives no agent and takes no turn:: + + @logic + def judge(said: Draft) -> Verdict: + return Verdict(done=said.text.endswith(".")) + + It may have several ways out, which is what a branch in an atlas's body is: the value it + answered with is read by the `if` or the `while` that follows it, and each way out is one + answer to that reading. + + Args: + call: The function, when the mark is written with no arguments at all. + rerun: Whether a run picked up again runs this node again where the last one stopped + inside it, which is what a node says by saying nothing. + + Returns: + The function, unchanged but for what it now says about itself. + """ + + def marks(said: Callable[P, T]) -> Callable[P, T]: + setattr(said, MARKED, Marked("logic", rerun=rerun)) + return said + + return marks if call is None else marks(call) + + +@overload +def atlas[**P, T](call: Callable[P, T], /) -> Callable[P, T]: ... + + +@overload +def atlas[**P, T]( + *, + name: str = "", + about: str = "", + skills: Iterable[str] = (), + selectable: bool = True, +) -> Callable[[Callable[P, T]], Callable[P, T]]: ... + + +def atlas[**P, T]( + call: Callable[P, T] | None = None, + /, + *, + name: str = "", + about: str = "", + skills: Iterable[str] = (), + selectable: bool = True, +) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]: + """Marks a function as an atlas: a flow whose body is a graph rather than a program. + + Everything :func:`~hmz.flows.flow` marks a flow with, this marks too -- the name, the + line it says about itself, the skills it works by, whether it is offered in a list -- so + an atlas is found, listed, chosen and run exactly as any other flow is. What it adds is + that the body is read instead of executed:: + + @atlas + def run(agents: Agents, task: str) -> None: + draft = write(agents.writer, task) + verdict = judge(draft) + + The body is a declaration. Each statement in it is one node; the branches between them + are the edges; and what actually runs is the prophecy that reading compiled, one node at a + time, which is what lets a run be picked up in the middle of one. + + An atlas can always be picked up again, and says so without being asked: a prophecy is a + list of nodes with an answer apiece, so what a run of one has done so far is something + the run itself writes down. Nothing in the body writes state and nothing is handed a + dict; a node that ran is a node whose answer was kept. + + An atlas that takes a shape rather than a task is a supernode and nothing else:: + + @atlas(name="review") + def review(agents: Agents, draft: Draft) -> Verdict: + ... + + Args: + call: The function, when the mark is written with no arguments at all. + name: What to call this one among the flows its file holds, or "" for the one it holds + under the file's own name. + about: One line saying what it does, defaulting to the first line of its docstring. + skills: The skills it works by that are somewhere else, one git URL apiece. + selectable: Whether to offer it in flow lists and the flow picker. + + Returns: + The function, unchanged but for what it now says about itself -- both marks, since an + atlas is a flow and everything that reads flows must go on reading this one. + """ + from . import flow + + def marks(said: Callable[P, T]) -> Callable[P, T]: + setattr(said, ATLAS, Atlas(name=name)) + return flow( + name=name, + about=about, + skills=skills, + resumable=True, + selectable=selectable, + )(said) + + return marks if call is None else marks(call) + + +# --------------------------------------------------------------------------------------- +# The prophecy itself: what an atlas compiles to, and what a run of one walks. +# --------------------------------------------------------------------------------------- + + +class Field(NamedTuple): + """One field of one shape, as the compiling read it off the model that declares it. + + Attributes: + name: What the field is called. + shape: The shape it holds, by name. + required: Whether the model refuses to be built without it, which is what an edge is + held to: what flows in has to cover what the far end cannot do without. + """ + + name: str + shape: str + required: bool + + +class Shape(NamedTuple): + """One thing that may flow along an edge, read off the atlas's own files. + + Attributes: + name: The model's name, or the plain kind -- `str`, `int`, `float`, `bool`. + fields: One per field the model declares, in the order it declares them, and nothing at + all for a plain kind, which has none. + """ + + name: str + fields: tuple[Field, ...] = () + + +class Reads(NamedTuple): + """Where one of a node's arguments comes from. + + A name rather than the node that answered it, because a body may bind a name twice -- + which is what a loop is, the second binding being the one the next round reads. So a run + keeps what each name holds now, and a node says which of them it wants. + + Attributes: + reads: The name it reads: one the body bound, or :data:`AGENTS` for the run's agents, + :data:`INPUT` for what the flow itself was called with, and :data:`CONFIG` for what + it was set up with. + field: The field read off it, and "" for the whole of it. + """ + + reads: str + field: str = "" + + +class When(NamedTuple): + """What has to hold for one edge to be the way out that is taken. + + Attributes: + reads: The name the branch reads, which is one a node bound. + field: The field read off it, and "" for the whole of it. + truth: Whether this is the way out taken when that reads as true or as false. + """ + + reads: str + field: str + truth: bool + + +class Node(NamedTuple): + """One node of a prophecy: one call site of the body it was compiled from. + + A node is a call site rather than a function, since a body that calls one function twice + is a prophecy with two nodes in it -- each with its own answer, its own place in the run, + and its own line in what a run picked up again has already done. + + Attributes: + at: The node id: what it calls, and `:2`, `:3` after it where the body calls that same + thing more than once. Read off the body's shape rather than off a line number, so + that a file reformatted compiles to the prophecy it already was. + kind: Which of the three it is. + calls: The function it runs, by the name the atlas's own files declare it under -- or, + for a supernode from another file, the flow by the name `-f` takes. + takes: Where each of its arguments comes from, in the order it takes them. + binds: The name its answer is bound to, and "" for a node whose answer nothing takes. + gives: The shape it answers with, and "" for a node that answers with nothing. + rerun: Whether a run picked up again runs it again where the last run stopped inside + it, or steps past it. + under: For a supernode, the prophecy it is, by the name that prophecy is called. "" for + every other node. + """ + + at: str + kind: Kind + calls: str + takes: tuple[Reads, ...] = () + binds: str = "" + gives: str = "" + rerun: bool = True + under: str = "" + + +class Edge(NamedTuple): + """One way from one node to the next. + + Attributes: + out_of: The node it leaves, and "" for the way into the prophecy. + into: The node it arrives at, and "" for the way out of it, which is where the run + ends. + when: What has to hold for this to be the way taken, and None for a node's only one. + """ + + out_of: str + into: str + when: When | None = None + + +class Prophecy(NamedTuple): + """One atlas, compiled: the whole of what a run of it will do. + + Attributes: + name: The flow, as it was asked for -- which for a supernode of another file is the + name `-f` takes, and for one beside it is that file's own name for it. + takes: The shape the flow is called with, which is `str` for one a command line runs + and a model for one that is only ever a supernode. + gives: The shape it answers with, and "" for one that answers with nothing. + agents: What the atlas calls each of the agents it drives, in the order it takes them. + nodes: Every node, by node id. + edges: Every way from one node to another, the way in and the way out included. + shapes: Every shape anything in it carries, the ones its supernodes carry included. + prophecies: One per supernode, which is the sub-atlas that node is. + """ + + name: str + takes: str + gives: str + agents: tuple[str, ...] + nodes: tuple[Node, ...] + edges: tuple[Edge, ...] + shapes: tuple[Shape, ...] + prophecies: tuple[Prophecy, ...] = () + + def node(self, at: str) -> Node | None: + """The node of that id, or None where the prophecy holds none. + + Args: + at: The node id. + + Returns: + The node. + """ + return next((one for one in self.nodes if one.at == at), None) + + def out_of(self, at: str) -> tuple[Edge, ...]: + """Every way out of one node, in the order they are to be tried. + + Args: + at: The node id, or "" for the way into the prophecy. + + Returns: + The edges, the guarded ones first: a node with a branch and a way out that is + taken otherwise is read as the branch it is rather than as a coin toss. + """ + found = [one for one in self.edges if one.out_of == at] + return tuple(sorted(found, key=lambda one: one.when is None)) + + def under(self, named: str) -> Prophecy | None: + """The sub-prophecy of that name, or None where this prophecy holds none. + + Args: + named: What the supernode said it was. + + Returns: + The prophecy. + """ + return next((one for one in self.prophecies if one.name == named), None) + + +def canonical(prophecy: Prophecy) -> str: + """One prophecy as the text two readings of the same atlas both answer with. + + Canonical means what it says: everything ordered by what it is rather than by where it + was written, so a body reformatted, a comment added or two nodes swapped where nothing + depends on the order compile to the same bytes. That is what makes :func:`digest` worth + keeping -- a run picked up again asks whether the atlas is still the atlas it was, and an + answer that changed when somebody reflowed a docstring would be no answer. + + Args: + prophecy: The compiled atlas. + + Returns: + JSON, keys sorted, one line: what a script diffs and what a person reads. + """ + import json + + return json.dumps(_written(prophecy), sort_keys=True, ensure_ascii=False) + + +def _written(prophecy: Prophecy) -> dict[str, object]: + """One prophecy as the plain objects :func:`canonical` writes out. + + Args: + prophecy: The compiled atlas. + + Returns: + Its nodes by id, its edges in order, its shapes by name and its sub-prophecies by name -- + each sorted, since the order a body happens to be written in is not part of what the + atlas is. + """ + return { + "name": prophecy.name, + "takes": prophecy.takes, + "gives": prophecy.gives, + "agents": list(prophecy.agents), + "nodes": [ + { + "at": one.at, + "kind": one.kind, + "calls": one.calls, + "takes": [[read.reads, read.field] for read in one.takes], + "binds": one.binds, + "gives": one.gives, + "rerun": one.rerun, + "under": one.under, + } + for one in sorted(prophecy.nodes) + ], + "edges": [ + { + "out_of": one.out_of, + "into": one.into, + "when": None + if one.when is None + else [one.when.reads, one.when.field, one.when.truth], + } + for one in sorted(prophecy.edges, key=_ordered) + ], + "shapes": [ + { + "name": one.name, + "fields": [ + [held.name, held.shape, held.required] for held in one.fields + ], + } + for one in sorted(prophecy.shapes) + ], + "prophecies": [ + _written(one) + for one in sorted(prophecy.prophecies, key=lambda one: one.name) + ], + } + + +def _ordered(edge: Edge) -> tuple[str, str, str, str, bool]: + """One edge as something two of them can be sorted by, an absent guard and all.""" + when = edge.when + return ( + edge.out_of, + edge.into, + "" if when is None else when.reads, + "" if when is None else when.field, + when is not None and when.truth, + ) + + +#: What a shipped prophecy is written with. Fixed rather than highest, so that the same +#: prophecy written by two installations is the same bytes -- a flowverse ships one, and a +#: file whose contents moved under a Python upgrade is a file every checkout re-writes. +_PROTOCOL = 5 + + +def kept(prophecy: Prophecy) -> bytes: + """One prophecy as the bytes a flowverse ships beside the atlas it compiled. + + Args: + prophecy: The compiled atlas. + + Returns: + What goes in `prophecy.pkl`. + """ + import pickle + + return pickle.dumps(prophecy, protocol=_PROTOCOL) + + +def told(said: bytes) -> Prophecy | None: + """One shipped prophecy read back, or None where those bytes are not one. + + Note: + Reading this runs what the bytes say, which is what reading a pickle is. That is the + trust a flowverse already has: a flow is a directory of Python and reading one means + running it, so a repository shipping a prophecy is a repository already trusted with + this machine. What this adds is the check that what came back is a prophecy, so that + a file which is merely corrupt is refused rather than walked. + + Args: + said: The bytes. + + Returns: + The prophecy, or None for bytes that are not one -- truncated, written by something + else, or written by a humanize whose prophecies had another shape. + """ + import pickle + + try: + held = pickle.loads(said) # noqa: S301 -- the note above: a flowverse is trusted + except Exception: # noqa: BLE001 -- anything a pickle raises is a file that is not one + return None + if not isinstance(held, Prophecy): + return None + try: + canonical(held) + except (AttributeError, TypeError, ValueError): + # A named tuple of the right class holding the wrong things: written by a humanize + # whose nodes had another shape, which is a prophecy to compile again rather than + # one to walk. + return None + return held + + +def digest(prophecy: Prophecy) -> str: + """What one compiled atlas is, in sixteen characters. + + What it is for is a run picked up again: what a run has already done is written down + against the prophecy it was doing it in, and an atlas rewritten between two runs of it is a + different prophecy whose nodes happen to share their names. Carrying on into it would be a + run resuming into somewhere it has never been, so the digest is checked and a run whose + prophecy has moved starts from the top. + + Args: + prophecy: The compiled atlas. + + Returns: + The first sixteen hex characters of the SHA-256 of :func:`canonical`. + """ + import hashlib + + return hashlib.sha256(canonical(prophecy).encode()).hexdigest()[:16] diff --git a/src/hmz/flows/checking.py b/src/hmz/flows/checking.py index 27431bb..99430fb 100644 --- a/src/hmz/flows/checking.py +++ b/src/hmz/flows/checking.py @@ -31,7 +31,7 @@ if TYPE_CHECKING: import os - from collections.abc import Iterator + from collections.abc import Iterator, Sequence __all__ = [ "Capability", @@ -116,6 +116,10 @@ def checked(flow: str | os.PathLike[str]) -> tuple[Finding, ...]: is safe to point at a flow nobody has read: what running the file would refuse is the second reading, :mod:`hmz.flows.proving`, which runs it in a process of its own. + A flow marked `@atlas` gets the stricter reading rather than this one, which is + :func:`hmz.flows.prophesying.prophesied`: an atlas is a flow whose body is compiled, so the + rules that read a body as a program would be reading it as something it is not. + Args: flow: The flow: its directory, or the Python file a single-file flow is. @@ -123,6 +127,43 @@ def checked(flow: str | os.PathLike[str]) -> tuple[Finding, ...]: One finding per thing found, in file order, and nothing at all for a flow this reading has nothing to say about -- which is not a proof, only a reading with nothing to say. """ + whole = _whole(flow) + if whole.compiled: + from .prophesying import prophesied + + return prophesied(flow, whole=whole).findings + return _rules(whole) + + +class _Whole(NamedTuple): + """One flow's files, parsed: what both readings start from. + + Attributes: + entry: Where the flow's entry point is. + read: One per file that parsed, in file order. + entered: The entry point's own file, or None where it could not be read. + found: What reading the files found before any rule ran -- a file that will not + parse, a directory that holds no flow. + compiled: Whether the entry point holds an atlas, which is a flow to compile rather + than a flow to read as a program. + """ + + entry: Path + read: list[_Read] + entered: _Read | None + found: list[Finding] + compiled: bool + + +def _whole(flow: str | os.PathLike[str]) -> _Whole: + """Parses every Python file one flow holds, and says which kind of flow it is. + + Args: + flow: The flow: its directory, or the Python file a single-file flow is. + + Returns: + The files and what parsing them found. Nothing is imported and nothing is executed. + """ from . import ENTRY at = Path(flow) @@ -140,14 +181,20 @@ def checked(flow: str | os.PathLike[str]) -> tuple[Finding, ...]: entry = at files = [at] if at.is_file() else [] if not entry.is_file(): - return ( - Finding( - "not-a-flow", - "error", - at, - 0, - "no flow to read: a flow is a directory with an __init__.py in it", - ), + return _Whole( + entry, + [], + None, + [ + Finding( + "not-a-flow", + "error", + at, + 0, + "no flow to read: a flow is a directory with an __init__.py in it", + ) + ], + compiled=False, ) found: list[Finding] = [] @@ -158,15 +205,30 @@ def checked(flow: str | os.PathLike[str]) -> tuple[Finding, ...]: found.append(held) else: read.append(held) - - marked = any(one.marks for one in read) entered = next((one for one in read if one.where == entry), None) - if entered is not None and not marked: + compiled = entered is not None and any(one.atlas for one in entered.marks) + return _Whole(entry, read, entered, found, compiled=compiled) + + +def _rules(whole: _Whole, skip: frozenset[str] = frozenset()) -> tuple[Finding, ...]: + """Every rule that reads a flow as a program, run over the files that parsed. + + Args: + whole: What :func:`_whole` read. + skip: Functions not to read as ordinary Python -- the bodies an atlas compiles, which + are declarations rather than programs and would be refused as both. + + Returns: + One finding per thing found, in file order. + """ + found = list(whole.found) + entered = whole.entered + if entered is not None and not any(one.marks for one in whole.read): found.append( Finding( "not-a-flow", "error", - entry, + whole.entry, 0, "nothing in it is marked @flow() -- a flow is a function marked with it, " "which is how a file says which of the functions in it is one", @@ -177,7 +239,7 @@ def checked(flow: str | os.PathLike[str]) -> tuple[Finding, ...]: Finding( "unsaid-flow", "warning", - entry, + whole.entry, 0, "the flow says nothing about itself -- the first line of this file's " "docstring is what every list of flows shows for it", @@ -186,13 +248,15 @@ def checked(flow: str | os.PathLike[str]) -> tuple[Finding, ...]: # What the flow declared about moments anywhere in its files, for the hooks it hangs: # a place annotated in the entry point covers a hook hung in the module beside it. - declared = frozenset(moment for one in read for moment in one.moments_declared) + declared = frozenset( + moment for one in whole.read for moment in one.moments_declared + ) asks = _Asks() - for one in read: + for one in whole.read: found.extend(_imports(one)) found.extend(_marks(one)) found.extend(_hooks(one, declared)) - found.extend(_functions(one, asks)) + found.extend(_functions(one, asks, skip)) return tuple(found) @@ -218,17 +282,47 @@ def checked(flow: str | os.PathLike[str]) -> tuple[Finding, ...]: "Driven": "driven", } +#: The two marks that make a function a node of a prophecy, by the name `hmz.flows` offers +#: each under. +_NODES = ("mind", "logic") + #: What an element of a plain tuple of agents may still be asked by name: the tuple's own #: two methods. A named place is a field of the NamedTuple the flow declared instead. _OF_A_TUPLE = frozenset({"count", "index"}) class _Mark(NamedTuple): - """One function a file marked with `@flow`, and what the mark said.""" + """One function a file marked as a flow, and what the mark said. + + Attributes: + node: The function. + name: What the mark called it inside its file, and "" for the one the file holds + under its own name. + resumable: Whether it says it can be picked up where the last run of it left off, + which an atlas always says. + atlas: Whether it was marked `@atlas` rather than `@flow` -- a flow whose body is + read rather than run, and which `prophesying.py` compiles. + """ node: ast.FunctionDef | ast.AsyncFunctionDef name: str resumable: bool + atlas: bool = False + + +class _Node(NamedTuple): + """One function a file marked `@mind` or `@logic`, and what the mark said. + + Attributes: + node: The function, which is what its parameters and its answer are read off. + kind: Which of the two it is. + rerun: Whether a run picked up again runs it again where the last one stopped inside + it, or steps past it. + """ + + node: ast.FunctionDef | ast.AsyncFunctionDef + kind: str + rerun: bool @dataclass @@ -241,6 +335,14 @@ class _Read: flow_alias: set[str] = field(default_factory=set[str]) moment_alias: set[str] = field(default_factory=set[str]) field_alias: set[str] = field(default_factory=set[str]) + #: And of :func:`hmz.flows.atlas`, :func:`hmz.flows.sub` and :func:`hmz.flows.load`: + #: the mark that says a flow is compiled, the one way an atlas reaches another, and the + #: one way an ordinary flow does -- which is the call an atlas may not write. + atlas_alias: set[str] = field(default_factory=set[str]) + sub_alias: set[str] = field(default_factory=set[str]) + load_alias: set[str] = field(default_factory=set[str]) + #: Local name -> which kind of node, for `mind` and `logic` as this file imports them. + node_alias: dict[str, str] = field(default_factory=dict[str, str]) #: Local name -> which interface, for every flow-facing interface the file imports. proto: dict[str, str] = field(default_factory=dict[str, str]) #: The NamedTuple and pydantic model classes the file itself declares. @@ -253,6 +355,12 @@ class _Read: marks: list[_Mark] = field(default_factory=list["_Mark"]) #: Every `Moment.X` written inside an annotation, which is a flow declaring a need. moments_declared: set[str] = field(default_factory=set[str]) + #: The functions this file marked `@mind` or `@logic`, by the name it declares each + #: under, and what each mark said. + nodes: dict[str, _Node] = field(default_factory=dict[str, "_Node"]) + #: And the atlases of other files it named, `: ` apiece, which is a + #: module-level `review = sub("official/review")`. + subs: dict[str, str] = field(default_factory=dict[str, str]) def _parsed(where: Path) -> _Read | Finding: @@ -306,6 +414,14 @@ def _collected(read: _Read, body: list[ast.stmt], *, type_checking: bool) -> Non read.bound.add(bound) if alias.name == "flow": read.flow_alias.add(bound) + elif alias.name == "atlas": + read.atlas_alias.add(bound) + elif alias.name == "sub": + read.sub_alias.add(bound) + elif alias.name == "load": + read.load_alias.add(bound) + elif alias.name in _NODES: + read.node_alias[bound] = alias.name elif alias.name == "Moment": read.moment_alias.add(bound) elif alias.name in _PROTOCOLS: @@ -324,6 +440,7 @@ def _collected(read: _Read, body: list[ast.stmt], *, type_checking: bool) -> Non elif isinstance(node, (ast.Assign, ast.AnnAssign)) and not type_checking: targets = node.targets if isinstance(node, ast.Assign) else [node.target] read.bound.update(one.id for one in targets if isinstance(one, ast.Name)) + _named_sub(read, targets, node.value) elif isinstance(node, ast.ClassDef) and not type_checking: read.bound.add(node.name) bases = {_root(base) for base in node.bases} @@ -337,6 +454,9 @@ def _collected(read: _Read, body: list[ast.stmt], *, type_checking: bool) -> Non mark = _marked(node, read) if mark is not None and not type_checking: read.marks.append(mark) + held = _noded(node, read) + if held is not None and not type_checking: + read.nodes[node.name] = held elif isinstance(node, ast.If): under = type_checking or _root(node.test) == "TYPE_CHECKING" _collected(read, node.body, type_checking=under) @@ -345,9 +465,38 @@ def _collected(read: _Read, body: list[ast.stmt], *, type_checking: bool) -> Non _collected(read, node.body, type_checking=type_checking) +def _named_sub( + read: _Read, targets: Sequence[ast.expr], value: ast.expr | None +) -> None: + """Records `review = sub("official/review")`, which is one supernode named. + + Args: + read: What is being collected into. + targets: What the statement assigns to. + value: What it assigns, which is only ever read where it is that call. + """ + if not ( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Name) + and value.func.id in read.sub_alias + and len(value.args) == 1 + and isinstance(value.args[0], ast.Constant) + and isinstance(value.args[0].value, str) + ): + return + for one in targets: + if isinstance(one, ast.Name): + read.subs[one.id] = value.args[0].value + + def _marked(node: ast.FunctionDef | ast.AsyncFunctionDef, read: _Read) -> _Mark | None: """The mark on one function, where it carries one. + Both marks: `@flow` and `@atlas` both make a function a flow, and everything that reads + a flow off a file reads both -- an atlas that was invisible here would be a flow nothing + could list, name or refuse. Which of the two it was is on the mark, for the one reading + that has to know. + Args: node: The function. read: The file it is in, for what the decorator is called there. @@ -356,21 +505,51 @@ def _marked(node: ast.FunctionDef | ast.AsyncFunctionDef, read: _Read) -> _Mark The mark, or None for a function that is not a flow. """ for one in node.decorator_list: - if isinstance(one, ast.Name) and one.id in read.flow_alias: - return _Mark(node, "", resumable=False) - if ( - isinstance(one, ast.Call) - and isinstance(one.func, ast.Name) - and one.func.id in read.flow_alias - ): - name = "" - resumable = False + called = one.func if isinstance(one, ast.Call) else one + if not isinstance(called, ast.Name): + continue + atlas = called.id in read.atlas_alias + if not atlas and called.id not in read.flow_alias: + continue + if not isinstance(one, ast.Call): + return _Mark(node, "", resumable=atlas, atlas=atlas) + name = "" + # An atlas can always be picked up: a prophecy is a list of nodes with an answer + # apiece, so what a run of one has done is something the run writes down itself. + resumable = atlas + for said in one.keywords: + if said.arg == "name" and isinstance(said.value, ast.Constant): + name = str(said.value.value) + elif ( + said.arg == "resumable" + and not atlas + and isinstance(said.value, ast.Constant) + ): + resumable = bool(said.value.value) + return _Mark(node=node, name=name, resumable=resumable, atlas=atlas) + return None + + +def _noded(node: ast.FunctionDef | ast.AsyncFunctionDef, read: _Read) -> _Node | None: + """The `@mind` or `@logic` mark on one function, where it carries one. + + Args: + node: The function. + read: The file it is in, for what the decorator is called there. + + Returns: + What the mark said, or None for a function that is not a node. + """ + for one in node.decorator_list: + called = one.func if isinstance(one, ast.Call) else one + if not isinstance(called, ast.Name) or called.id not in read.node_alias: + continue + rerun = True + if isinstance(one, ast.Call): for said in one.keywords: - if said.arg == "name" and isinstance(said.value, ast.Constant): - name = str(said.value.value) - elif said.arg == "resumable" and isinstance(said.value, ast.Constant): - resumable = bool(said.value.value) - return _Mark(node=node, name=name, resumable=resumable) + if said.arg == "rerun" and isinstance(said.value, ast.Constant): + rerun = bool(said.value.value) + return _Node(node=node, kind=read.node_alias[called.id], rerun=rerun) return None @@ -502,7 +681,10 @@ def _marks(read: _Read) -> Iterator[Finding]: ) named.add(mark.name) yield from _sized(mark, read) - yield from _resumes(mark, read) + # An atlas is resumable without taking a dict to be resumed with: what a run of one + # has done is which of its nodes have answered, which the run writes down itself. + if not mark.atlas: + yield from _resumes(mark, read) for model in _settings(mark.node, read): if model not in configured: configured.add(model) @@ -851,18 +1033,22 @@ def forgot(self, name: str) -> None: self.answers.pop(name, None) -def _functions(read: _Read, asks: _Asks) -> Iterator[Finding]: +def _functions( + read: _Read, asks: _Asks, skip: frozenset[str] = frozenset() +) -> Iterator[Finding]: """Reads every function in one file for what it asks and how its loops end. Args: read: The file. asks: The interface surfaces, shared across the files of one checking. + skip: Functions not to read -- the bodies an atlas compiles, which are declarations + rather than programs. Yields: The findings, function by function. """ for node in read.tree.body: - yield from _defined(node, read, asks, {}) + yield from _defined(node, read, asks, {}, skip) def _defined( @@ -870,16 +1056,18 @@ def _defined( read: _Read, asks: _Asks, inherited: dict[str, frozenset[str] | _Crew], + skip: frozenset[str] = frozenset(), ) -> Iterator[Finding]: """One top-level statement, read for the functions in it.""" if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - yield from _function(node, read, asks, inherited) + if node.name not in skip: + yield from _function(node, read, asks, inherited) elif isinstance(node, ast.ClassDef): for held in node.body: - yield from _defined(held, read, asks, inherited) + yield from _defined(held, read, asks, inherited, skip) elif isinstance(node, ast.If): for held in [*node.body, *node.orelse]: - yield from _defined(held, read, asks, inherited) + yield from _defined(held, read, asks, inherited, skip) def _function( diff --git a/src/hmz/flows/driving.py b/src/hmz/flows/driving.py index 8b341bb..debde1e 100644 --- a/src/hmz/flows/driving.py +++ b/src/hmz/flows/driving.py @@ -472,9 +472,10 @@ def declares( """ from . import find, inside, loaded + named = str(flow) # Which of the file's flows was asked for, before the name is resolved to a file: a file # may hold several, and `humanize1:gen-plan` is one of them. - wanted = inside(str(flow)) + wanted = inside(named) # Resolved here rather than by whoever is starting one, so that a name works wherever a # flow is named -- a command line, an interface, a `Runner` written by hand. flow = find(str(flow)) @@ -528,7 +529,7 @@ def declares( ): kinds = _kinds(declared, run) return ( - run, + _compiled(named, read, run), tuple(_place(at, kinds.get(at)) for at in fields), declared._make, _setting(run, hinted), @@ -543,7 +544,7 @@ def declares( "flow drives -- or with a NamedTuple of them, which also says what each is for" ) return ( - run, + _compiled(named, read, run), tuple(_place("", kind) for kind in declares), tuple, _setting(run, hinted), @@ -551,6 +552,35 @@ def declares( ) +def _compiled(named: str, read: dict[str, Any], run: Entry) -> Entry: + """One flow's entry point, or -- for an atlas -- something that runs its prophecy. + + An atlas is a flow whose body is a declaration: what it says is compiled before anything + runs, and what runs is the prophecy that compiling made. So the entry point itself is + never called, and what everything else holds is the walk over the prophecy instead -- + read here, where a flow is loaded, so that every way of running one gets both the + checking and the walking without knowing there are two kinds of flow. + + Args: + named: The flow, as it was asked for. + read: What running its file left behind. + run: Its entry point. + + Returns: + The entry point for an ordinary flow, and the walk for an atlas. + + Raises: + NotAFlow: If the atlas does not compile, which is a flow refused where it is named. + """ + from .atlas import ATLAS + + if getattr(run, ATLAS, None) is None: + return run + from .stepping import walking + + return walking(named, read, run) + + def _settles(agent: Agent) -> Driven: """One agent as whoever hands it to a flow holds it, rather than as a flow does. diff --git a/src/hmz/flows/prophesying.py b/src/hmz/flows/prophesying.py new file mode 100644 index 0000000..027d937 --- /dev/null +++ b/src/hmz/flows/prophesying.py @@ -0,0 +1,1469 @@ +"""Compiling an atlas: the reading that turns a body into the prophecy a run walks. + +An ordinary flow is read by running it, and the one thing nothing can ask it is what it is +about to do. An atlas answers that question before anything runs: its body is a declaration +in a narrower Python, and this is the reading that holds it to that Python and compiles what +it declared into an :class:`~hmz.flows.atlas.Prophecy`. + +Pure `ast`, like :mod:`hmz.flows.checking`, and for the same reason: the atlas most worth +compiling is one nobody has read yet -- generated, fetched, forked -- and a compiler that ran +what it was compiling would be the attack it exists to catch. So an atlas is read, and +compiled, and only then loaded to be run. + +Every rule here is an error, and every one of them is decidable. That is the bargain an atlas +makes. The reading of an ordinary flow proves absences one function at a time and warns where +it cannot be sure; an atlas is written in the subset where there is nothing to be unsure +about. What flows along an edge either fits what the far end takes or it does not; a branch +either hangs off a logic node or it does not. That reading's warnings still come back over +the node bodies, and still do not block: a node body is ordinary Python, and is read as it. + +The subset, in one place. A body holds only these: + +- ``x = call(a, b)`` and ``call(a, b)`` -- one node apiece, whose arguments are names the + body has bound, fields read off them, or one of the flow's own three: the agents, what it + was called with, what it was set up with. +- ``if x:`` and ``if not x.field:``, with an ``else`` or without, which is a node's several + ways out. +- ``while x:`` and ``while not x.field:``, which is that with an edge back to the node that + answered the name being read. +- ``return`` and ``return x``, which is where the run ends. +- ``pass``, and the docstring. + +And nothing else. Arithmetic, comprehensions, ``try``, ``with``, ``import``, a call written +inside another call: each of them is a thing a node does, and a node is where each of them +goes. An atlas is the shape of the work rather than the work. +""" + +from __future__ import annotations + +import ast +from pathlib import Path +from typing import TYPE_CHECKING, NamedTuple + +from .atlas import ( + AGENTS, + CONFIG, + INPUT, + Edge, + Field, + Node, + Prophecy, + Reads, + Shape, + When, + digest, +) + +# The reading beside this one, whose parsing, whose rules and whose small readings of a tree +# this shares: an atlas is a flow, and the whole of what makes it one is read there. Its +# public surface is what a flow-checker is asked for, and these are two readings of one +# package sharing what one of them wrote down -- not a second copy of it, kept here to drift. +from .checking import ( + Finding, + _elements, # pyright: ignore[reportPrivateUsage] + _Mark, # pyright: ignore[reportPrivateUsage] + _names_in, # pyright: ignore[reportPrivateUsage] + _Node, # pyright: ignore[reportPrivateUsage] + _Read, # pyright: ignore[reportPrivateUsage] + _root, # pyright: ignore[reportPrivateUsage] + _rules, # pyright: ignore[reportPrivateUsage] + _tip, # pyright: ignore[reportPrivateUsage] + _Whole, # pyright: ignore[reportPrivateUsage] + _whole, # pyright: ignore[reportPrivateUsage] +) + +if TYPE_CHECKING: + import os + + from .atlas import Kind + +__all__ = ["Prophesied", "prophesied"] + +#: The shapes an atlas may carry that are not models: the plain kinds a node may take and +#: answer with. Anything else has fields, and a thing with fields is a model -- so that what +#: flows along an edge is something both ends can be held to. +PLAIN = ("str", "int", "float", "bool") + +#: What a node that answers with nothing writes where a shape would go. +NOTHING = "None" + +#: What one node's parameter says where a shape would be, for the two places an atlas hands +#: over what the run was started with rather than anything a node answered: the agent a mind +#: drives, and the whole tuple of them a supernode is handed. +ONE_AGENT = "@agent" +THE_AGENTS = "@agents" + +#: How many things an atlas's entry point takes: the agents and what it is called with, and +#: for one that says it can be set up, the config after them. +_TAKES = 2 +_AND_A_CONFIG = 3 + + +class Prophesied(NamedTuple): + """What compiling one atlas came to. + + Attributes: + findings: One per thing the reading found, in file order. Every error among them is a + reason the atlas did not compile. + prophecy: The compiled atlas, or None where anything was an error: a graph built out of + a body the reading refused would be a graph of something nobody wrote. + """ + + findings: tuple[Finding, ...] + prophecy: Prophecy | None + + +def prophesied( + flow: str | os.PathLike[str], + *, + name: str = "", + whole: _Whole | None = None, + through: tuple[tuple[str, str], ...] = (), +) -> Prophesied: + """Reads an atlas without running it, and compiles what its body declared. + + Args: + flow: The atlas: its directory, or the Python file a single-file one is. + name: Which of the atlases the file holds, and "" for the one it holds under its own + name -- the half after the colon in `official/review:pass`. + whole: The files already parsed, for a caller that has read them, which is + :func:`hmz.flows.checking.checked` handing on the reading it has already done. + through: The atlases this one is being compiled inside -- where each is and what it + was named -- so that a supernode reaching back into one of them is refused rather + than followed forever. + + Returns: + The findings and, where none of them is an error, the prophecy. + """ + whole = _whole(flow) if whole is None else whole + if not whole.compiled or whole.entered is None: + return Prophesied( + ( + _said( + "not-an-atlas", + whole.entry, + 0, + "nothing in it is marked @atlas -- an atlas is a flow whose body is " + "compiled, which is how a file says which of its flows is one", + ), + ), + None, + ) + # The bodies an atlas compiles are declarations rather than programs, so the rules that + # read a body as a program are not run over them: an `if` with no `elif` is a branch + # here, and a `while` with no `break` is an edge back to a node. + declared = frozenset( + one.node.name for read in whole.read for one in read.marks if one.atlas + ) + found = list(_rules(whole, declared)) + for read in whole.read: + found.extend(_dynamic(read)) + mark = next( + (one for one in whole.entered.marks if one.atlas and one.name == name), None + ) + if mark is None: + held = sorted(one.name for one in whole.entered.marks if one.atlas) + found.append( + _said( + "not-an-atlas", + whole.entry, + 0, + f"nothing in it is an atlas called {name!r} -- it holds " + f"{', '.join(repr(one) for one in held)}", + ) + ) + return Prophesied(tuple(found), None) + prophecy, said = _compiled(whole, mark, name or _stem(whole), through) + found.extend(said) + if prophecy is not None: + found.extend(_shipped(whole, prophecy)) + if any(one.severity == "error" for one in found): + return Prophesied(tuple(found), None) + return Prophesied(tuple(found), prophecy) + + +def _shipped(whole: _Whole, prophecy: Prophecy) -> list[Finding]: + """Whether the prophecy a flow ships is the one its source compiles to. + + A flowverse may ship `prophecy.pkl` beside an atlas, and that is what a run of it walks. + So a shipped prophecy which is no longer what the source says is a flow that does one + thing and reads as another -- the one thing shipping it was meant to rule out. + + Args: + whole: The parsed files. + prophecy: What the source compiles to now. + + Returns: + A `stale-prophecy` error where the two differ, and nothing where they agree or where + the flow ships none. + """ + from . import PROPHECY + from .atlas import told + + at = whole.entry.parent / PROPHECY + if not at.is_file(): + return [] + held = told(at.read_bytes()) + if held is not None and held.name != prophecy.name: + return [] + if held is None: + return [ + _said( + "stale-prophecy", + at, + 0, + "the prophecy shipped here cannot be read back -- compile the atlas again, " + "or take the file away and let each run compile it", + ) + ] + if digest(held) != digest(prophecy): + return [ + _said( + "stale-prophecy", + at, + 0, + f"the prophecy shipped here is {digest(held)} and this source compiles to " + f"{digest(prophecy)} -- a run walks the shipped one, so the flow does one " + "thing and reads as another", + ) + ] + return [] + + +def _dynamic(read: _Read) -> list[Finding]: + """Every place one file reaches for a flow that is not an atlas. + + An atlas calls an atlas. `load` answers with a flow that may be anything -- a loop, a + branch, a week of turns -- and a prophecy with one of those in it would be a graph with + a hole where a node should be, which is the one thing a prophecy is for not having. + + Args: + read: The file. + + Returns: + A `dynamic-call` error per import of `load`, said where it is imported rather than + where it is called: a name a file has is a name a body may reach for. + """ + if not read.load_alias: + return [] + return [ + _said( + "dynamic-call", + read.where, + node.lineno, + "an atlas calls an atlas: load() answers with a flow that may be anything, " + "and what an atlas reaches another by is sub(), which is compiled into the " + "prophecy reaching for it", + ) + for node in ast.walk(read.tree) + if isinstance(node, ast.ImportFrom) + and node.module == "hmz.flows" + and any(one.name == "load" for one in node.names) + ] + + +def _stem(whole: _Whole) -> str: + """What the atlas a file holds under its own name is called, which is the file's.""" + from . import ENTRY + + at = whole.entry + return at.parent.name if at.name == ENTRY else at.stem + + +# --------------------------------------------------------------------------------------- +# What the flow's files declare, gathered across them. +# --------------------------------------------------------------------------------------- + + +class _Held(NamedTuple): + """Everything one atlas's files declare, gathered across them. + + A flow is a directory, and what it declares is spread over the files in it: the models + in one, the nodes in another, the atlas itself in the entry point. What resolves a name + in a body is therefore the whole directory rather than the file the body is in. + + Attributes: + models: The pydantic models, by name -- what a node may take and answer with. + crews: The NamedTuples, by name -- what a flow declares its agents as. + nodes: The functions marked `@mind` or `@logic`, by name. + atlases: The functions marked `@atlas`, by name, each beside the file it is in. + subs: The atlases of other files this one named, `: ` apiece. + protos: The local names of the flow-facing interfaces, which is how an agent reads. + """ + + models: dict[str, ast.ClassDef] + crews: dict[str, ast.ClassDef] + nodes: dict[str, _Node] + atlases: dict[str, tuple[_Read, _Mark]] + subs: dict[str, str] + protos: set[str] + + +def _gathered(whole: _Whole) -> _Held: + """What every file of one atlas declares, in one place. + + Args: + whole: The parsed files. + + Returns: + The declarations, by name. + """ + held = _Held({}, {}, {}, {}, {}, set()) + for read in whole.read: + held.models.update(read.models) + held.crews.update(read.crews) + held.nodes.update(read.nodes) + held.subs.update(read.subs) + held.protos.update(read.proto) + for mark in read.marks: + if mark.atlas: + held.atlases[mark.node.name] = (read, mark) + return held + + +# --------------------------------------------------------------------------------------- +# The entry point read: what it drives, what it is called with, what it answers with. +# --------------------------------------------------------------------------------------- + + +def _compiled( + whole: _Whole, mark: _Mark, named: str, through: tuple[tuple[str, str], ...] +) -> tuple[Prophecy | None, list[Finding]]: + """One atlas's entry point read, and its body walked into a prophecy. + + Args: + whole: The parsed files. + mark: The atlas being compiled. + named: What to call the prophecy, which is the name the flow is asked for by. + through: The atlases this one is inside, for the supernode that reaches back. + + Returns: + The prophecy, or None where the reading refused it, and everything the reading found. + """ + where = next((one.where for one in whole.read if mark in one.marks), whole.entry) + held = _gathered(whole) + found: list[Finding] = [] + node = mark.node + params = [*node.args.posonlyargs, *node.args.args] + if isinstance(node, ast.AsyncFunctionDef): + found.append( + _said( + "unstatic-body", + where, + node.lineno, + "an atlas is compiled rather than awaited -- what takes time is a node, " + "and a node is where an `async def` goes", + ) + ) + if not _TAKES <= len(params) <= _AND_A_CONFIG: + found.append( + _said( + "unshaped-node", + where, + node.lineno, + "an atlas takes the agents and what it is called with, and after them a " + f"config for one that says it can be set up -- this takes {len(params)}", + ) + ) + return None, found + agents = _agents(params[0], held, where, found) + takes = _kind( + params[1].annotation, held, "what the atlas is called with", where, found + ) + gives = _kind(node.returns, held, "what the atlas answers with", where, found) + config = ( + _kind(params[2].annotation, held, "what an atlas is set up with", where, found) + if len(params) == _AND_A_CONFIG + else "" + ) + wiring = _Wiring( + whole=whole, + held=held, + where=where, + agents=agents, + takes=takes, + gives="" if gives == NOTHING else gives, + config=config, + names={params[0].arg: AGENTS, params[1].arg: INPUT} + | ({params[2].arg: CONFIG} if len(params) == _AND_A_CONFIG else {}), + through=(*through, (_who(where, mark.name), named)), + ) + wiring.walk(node.body) + found.extend(wiring.found) + if any(one.severity == "error" for one in found): + return None, found + return ( + Prophecy( + name=named, + takes=takes, + gives=wiring.gives, + agents=agents, + nodes=tuple(wiring.nodes), + edges=tuple(wiring.edges), + shapes=tuple(_shapes(wiring.carried, held)), + prophecies=tuple(wiring.prophecies), + ), + found, + ) + + +def _kind( + annotation: ast.expr | None, + held: _Held, + called: str, + where: Path, + found: list[Finding], +) -> str: + """One shape an atlas's entry point declares, having said so where it declares none. + + Args: + annotation: The annotation. + held: What the flow's files declare. + called: What this place is, for a finding. + where: The file. + found: What to add a finding to. + + Returns: + The shape, and "" for an annotation that names none. + """ + shape = _shape(annotation, held) + if shape: + return shape + found.append( + _said( + "unshaped-node", + where, + annotation.lineno if annotation is not None else 0, + f"{called} is annotated {_wrote(annotation)}, which is no shape -- a model it " + f"declares, one of {', '.join(PLAIN)}, or None for nothing at all", + ) + ) + return "" + + +def _agents( + param: ast.arg, held: _Held, where: Path, found: list[Finding] +) -> tuple[str, ...]: + """What one atlas calls each of the agents it drives, read off its first parameter. + + An atlas declares its agents as a NamedTuple and not as a plain tuple of them, which is + the one thing an ordinary flow may leave unsaid: every turn in a prophecy is a node that + names the agent it drives, and a place with no name is a turn nothing can be pointed at. + + Args: + param: The entry point's first parameter. + held: What the flow's files declare. + where: The file, for a finding. + found: What to add a finding to. + + Returns: + One name per agent, in the order the flow takes them. + """ + crew = held.crews.get(_root(param.annotation) if param.annotation else "") + if crew is None: + found.append( + _said( + "unnamed-agents", + where, + param.lineno, + "an atlas declares its agents as a NamedTuple of them, so that every turn " + f"names the agent it drives -- {_wrote(param.annotation)} says only how " + "many there are", + ) + ) + return () + named: list[str] = [] + for one in crew.body: + if not isinstance(one, ast.AnnAssign) or not isinstance(one.target, ast.Name): + continue + if not _names_in(one.annotation) & held.protos: + found.append( + _said( + "unknown-agent", + where, + one.lineno, + f"{one.target.id} is annotated {_wrote(one.annotation)}, which is not " + "an agent -- what an atlas takes first is the agents it drives and " + "nothing else", + ) + ) + continue + named.append(one.target.id) + return tuple(named) + + +# --------------------------------------------------------------------------------------- +# The body walked: one node per call, one edge per way from one to the next. +# --------------------------------------------------------------------------------------- + +#: One loose end of a body being walked: the node it leaves and what has to hold to be +#: leaving by it, where "" is the way into the prophecy and None is a node's only way out. +type _Loose = list[tuple[str, When | None]] + +#: What one node takes, as the reading of its declaration left it: the parameter's name and +#: the shape it holds, which may be one of the two agent kinds instead. +type _Takes = list[tuple[str, str]] + +#: One node's declaration: what kind it is, what it takes, what it answers with, whether a +#: run picked up inside it runs it again, and -- for a supernode -- the prophecy it is. +type _Declared = tuple["Kind", _Takes, str, bool, str] + + +class _Wiring: + """One atlas's body being walked into nodes and edges. + + A body is a chain of statements, and the loose ends between them are what the next + statement is wired to. A branch splits the loose ends and guards each half; a loop wires + them back to the node whose answer the loop reads; a return wires them to the end. + """ + + def __init__( + self, + *, + whole: _Whole, + held: _Held, + where: Path, + agents: tuple[str, ...], + takes: str, + gives: str, + config: str, + names: dict[str, str], + through: tuple[tuple[str, str], ...], + ) -> None: + """Holds what one body is being walked into. + + Args: + whole: The parsed files, for a supernode beside this one. + held: What the flow's files declare. + where: The file the body is in. + agents: What the atlas calls each of the agents it drives. + takes: The shape the atlas is called with. + gives: The shape it answers with, and "" for one that answers with nothing. + config: The shape it can be set up with, and "" for one that takes no setting up. + names: The entry point's own parameters, by what the body calls each. + through: The atlases this body is inside, for the supernode that reaches back. + """ + self.whole = whole + self.held = held + self.where = where + self.agents = agents + self.takes = takes + self.gives = gives + self.config = config + self.names = names + self.through = through + self.nodes: list[Node] = [] + self.edges: list[Edge] = [] + self.prophecies: list[Prophecy] = [] + self.found: list[Finding] = [] + #: What each name the body has bound holds, by shape. A name keeps the shape it was + #: first bound with: a loop binds the same name every round, and one whose shape + #: moved would be an edge that fits on the first round and not on the second. + self.bound: dict[str, str] = {} + #: How many nodes each callee has been so far, for the id the next one gets. + self.seen: dict[str, int] = {} + #: Every shape anything in this prophecy carries, for the shapes it is written with. + self.carried: set[str] = {one for one in (takes, gives, config) if one} + + def walk(self, body: list[ast.stmt]) -> None: + """Walks the whole of one atlas's body, and ends whatever it leaves open. + + Args: + body: The entry point's statements. + """ + for out_of, when in self._block(body, [("", None)]): + self.edges.append(Edge(out_of, "", when)) + if not self.nodes: + self.found.append( + _said( + "unstatic-body", + self.where, + body[0].lineno if body else 0, + "an atlas with no nodes in it is not a graph -- a body is a call " + "apiece to the minds and logics that do the work", + ) + ) + + # -- the statements ------------------------------------------------------------- + + def _block(self, body: list[ast.stmt], loose: _Loose) -> _Loose: + """One run of statements, each wired to whatever the last one left open. + + Args: + body: The statements. + loose: The ends coming in. + + Returns: + The ends left open at the bottom, which is nothing at all after a `return`. + """ + for at, one in enumerate(body): + if _is_docstring(one, at) or isinstance(one, ast.Pass): + continue + if not loose: + self._refuse(one, "nothing here can run: the atlas ends above it") + return [] + if isinstance(one, ast.Assign) and isinstance(one.value, ast.Call): + loose = self._call(one.value, self._target(one), loose) + elif isinstance(one, ast.Expr) and isinstance(one.value, ast.Call): + loose = self._call(one.value, "", loose) + elif isinstance(one, ast.If): + loose = self._branch(one, loose) + elif isinstance(one, ast.While): + loose = self._loop(one, loose) + elif isinstance(one, ast.Return): + self._return(one, loose) + loose = [] + else: + self._refuse(one, f"`{_wrote(one).splitlines()[0]}` is not one of them") + return loose + + def _return(self, node: ast.Return, loose: _Loose) -> None: + """A `return`, which is where the run ends. + + Args: + node: The statement. + loose: The ends arriving at it. + """ + given = NOTHING + if node.value is not None: + read = self._reads(node.value) + if read is None: + self._refuse(node, "an atlas returns a name a node bound, or nothing") + return + given = self._shape_of(read) or NOTHING + if given != (self.gives or NOTHING): + self.found.append( + _said( + "shape-mismatch", + self.where, + node.lineno, + f"the atlas answers with {self.gives or NOTHING} and this returns " + f"{given}", + ) + ) + for out_of, when in loose: + self.edges.append(Edge(out_of, "", when)) + + def _branch(self, node: ast.If, loose: _Loose) -> _Loose: + """An `if`, which is the several ways out of the node above it. + + Args: + node: The statement. + loose: The ends arriving at it, each of which the branch guards. + + Returns: + The ends both arms left open. + """ + read = self._branched(node.test, loose) + if read is None: + return loose + reads, field, truth = read + taken: _Loose = [(out_of, When(reads, field, truth)) for out_of, _ in loose] + otherwise: _Loose = [ + (out_of, When(reads, field, not truth)) for out_of, _ in loose + ] + return [*self._block(node.body, taken), *self._block(node.orelse, otherwise)] + + def _loop(self, node: ast.While, loose: _Loose) -> _Loose: + """A `while`, which is a branch with an edge back to the node it reads. + + The node above the loop is its head: it answers the name the test reads, the body + runs while that holds, and the body's last node wires back to the head -- so the + head answers again with whatever the round changed, which is what ends the loop. + + Args: + node: The statement. + loose: The ends arriving at it, which is one and no more. + + Returns: + The one end the loop leaves open, guarded by the test not holding. + """ + if node.orelse: + self._refuse( + node, "a `while` in an atlas has no `else`: the loop is its edges" + ) + return loose + if len(loose) != 1: + self._refuse( + node, + "a loop leaves the one node it reads again each round -- put a logic node " + "between the branch above and this, so the loop has a head", + ) + return loose + read = self._branched(node.test, loose) + if read is None: + return loose + head = loose[0][0] + reads, field, truth = read + opens: _Loose = [(head, When(reads, field, truth))] + inside = self._block(node.body, opens) + for out_of, when in inside: + self.edges.append(Edge(out_of, head, when)) + self._endless(node, head) + ends: _Loose = [(head, When(reads, field, not truth))] + return ends + + def _endless(self, node: ast.While, head: str) -> None: + """Whether one loop's head can ever answer differently, which is whether it ends. + + Args: + node: The loop. + head: The node it reads again each round, by node id. + """ + wrote = { + one.id + for said in ast.walk(node) + if isinstance(said, ast.Assign) + for one in said.targets + if isinstance(one, ast.Name) + } + reading = next((one for one in self.nodes if one.at == head), None) + if reading is not None and not wrote & {one.reads for one in reading.takes}: + self.found.append( + _said( + "dead-loop", + self.where, + node.lineno, + f"nothing in this loop changes what {head} reads, so it answers the " + "same thing every round and the loop never ends", + ) + ) + + def _branched(self, test: ast.expr, loose: _Loose) -> tuple[str, str, bool] | None: + """What one branch reads, and whether the nodes above it may be branched on. + + Args: + test: The `if` or `while` test. + loose: The ends arriving at the branch. + + Returns: + The name read, the field read off it, and whether the first way out is the one + taken when that reads as true. None where the branch is refused. + """ + said = test.operand if isinstance(test, ast.UnaryOp) else test + truth = not _is_not(test) + read = self._reads(said) + if read is None: + self._refuse( + test, + f"a branch reads a name a node bound, or one field of it -- `{_wrote(test)}`" + " is work, and work is what a logic node is for", + ) + return None + if self._shape_of(read) is None: + self.found.append( + _said( + "unbound-read", + self.where, + test.lineno, + f"nothing here has bound {_names(read)}", + ) + ) + return None + for out_of, when in loose: + if when is not None: + self._refuse( + test, + "a branch follows a node and not another branch -- an `elif`, or an " + "arm with nothing in it, is two decisions carried on one edge; put a " + "logic node between them so each way out belongs to what decided it", + ) + return None + above = self._above(out_of) + if above is None: + self._refuse( + test, "a branch follows a node, and nothing has run here yet" + ) + return None + if above.kind == "mind": + self.found.append( + _said( + "branching-mind", + self.where, + test.lineno, + f"{out_of} is a turn, and a turn has one way out -- read what it " + "answered with a logic node, and branch on that", + ) + ) + return None + return (*read, truth) + + def _above(self, at: str) -> Node | None: + """The node of that id, or None for the way into the prophecy.""" + return next((one for one in self.nodes if one.at == at), None) + + def _target(self, node: ast.Assign) -> str: + """The one name an assignment binds, having said so where it binds anything else.""" + if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name): + return node.targets[0].id + self._refuse( + node, + "a node binds one name -- nothing here is unpacked, and nothing bound twice", + ) + return "" + + # -- one node ------------------------------------------------------------------- + + def _call(self, call: ast.Call, binds: str, loose: _Loose) -> _Loose: + """One call, which is one node of the prophecy. + + Args: + call: The call. + binds: The name its answer is bound to, and "" for one nothing takes. + loose: The ends arriving at it. + + Returns: + The one end it leaves open. + """ + if not isinstance(call.func, ast.Name): + self._refuse(call, "a node is one call to a name this flow declares") + return loose + called = call.func.id + if call.keywords or any(isinstance(one, ast.Starred) for one in call.args): + self._refuse( + call, + "a node is handed its arguments in order, by name -- no keywords and " + "nothing unpacked, so that what flows along each edge is one thing", + ) + return loose + declared = self._declared(call, called) + if declared is None: + return loose + kind, takes, gives, rerun, under = declared + reads = self._arguments(call, called, takes) + if reads is None: + return loose + if not rerun and gives: + self.found.append( + _said( + "skipped-answer", + self.where, + call.lineno, + f"{called} is stepped past when a run is picked up inside it, and " + f"answers with {gives} -- a node a run steps past has no answer for " + "what comes next, so such a node answers with nothing", + ) + ) + at = self._id(called) + self.nodes.append( + Node( + at=at, + kind=kind, + calls=under or called, + takes=tuple(reads), + binds=binds, + gives=gives, + rerun=rerun, + under=under, + ) + ) + self.carried.update( + shape for _, shape in takes if shape not in (ONE_AGENT, THE_AGENTS) + ) + if gives: + self.carried.add(gives) + if binds: + self._binds(call, binds, gives) + for out_of, when in loose: + self.edges.append(Edge(out_of, at, when)) + return [(at, None)] + + def _declared(self, call: ast.Call, called: str) -> _Declared | None: + """What the thing one call names is, and what it takes and answers with. + + Args: + call: The call, for a finding. + called: The name it calls. + + Returns: + Its declaration, or None where the name is not something an atlas may call. + """ + held = self.held.nodes.get(called) + if held is not None: + return self._noded(call, called, held) + if called in self.held.atlases or called in self.held.subs: + return self._supernode(call, called) + self._refuse( + call, + f"{called} is not a node: an atlas calls what it marked @mind or @logic, an " + "atlas beside it, or one it named with sub()", + ) + return None + + def _noded(self, call: ast.Call, called: str, held: _Node) -> _Declared | None: + """One `@mind` or `@logic` node, read off the function that declares it. + + Args: + call: The call, for a finding. + called: The name it calls. + held: What the mark said. + + Returns: + Its declaration, or None where the function's shapes cannot be read. + """ + params = [*held.node.args.posonlyargs, *held.node.args.args] + takes: _Takes = [] + for at, one in enumerate(params): + agent = bool( + one.annotation and _names_in(one.annotation) & self.held.protos + ) + if agent and at == 0 and held.kind == "mind": + takes.append((one.arg, ONE_AGENT)) + continue + if agent: + self.found.append( + _said( + "unagented-node", + self.where, + call.lineno, + f"{called} takes an agent as {one.arg} -- a mind takes one and it " + "is the first thing it takes, and a logic takes none at all", + ) + ) + return None + shape = _shape(one.annotation, self.held) + if not shape or shape == NOTHING: + self.found.append( + _said( + "unshaped-node", + self.where, + call.lineno, + f"{called} takes {one.arg} annotated {_wrote(one.annotation)}, " + "which is no shape -- a node takes a model this flow declares, or " + f"one of {', '.join(PLAIN)}", + ) + ) + return None + takes.append((one.arg, shape)) + if held.kind == "mind" and not (takes and takes[0][1] == ONE_AGENT): + self.found.append( + _said( + "unagented-node", + self.where, + call.lineno, + f"{called} is a turn and takes no agent -- what a mind takes first is " + "the agent it drives", + ) + ) + return None + gives = _shape(held.node.returns, self.held) + if not gives: + self.found.append( + _said( + "unshaped-node", + self.where, + call.lineno, + f"{called} answers with {_wrote(held.node.returns)}, which is no " + f"shape -- a model this flow declares, one of {', '.join(PLAIN)}, or " + "None for nothing at all", + ) + ) + return None + kind: Kind = "mind" if held.kind == "mind" else "logic" + return kind, takes, "" if gives == NOTHING else gives, held.rerun, "" + + def _supernode(self, call: ast.Call, called: str) -> _Declared | None: + """One supernode: a whole atlas, compiled into the prophecy reaching for it. + + Args: + call: The call, for a finding. + called: The name it calls. + + Returns: + Its declaration, or None where the atlas under it did not compile. + """ + named = self.held.subs.get(called, called) + under = next((one for one in self.prophecies if one.name == named), None) + if under is None: + under = self._under(call, called, named) + if under is None: + return None + self.prophecies.append(under) + short = set(under.agents) - set(self.agents) + if short: + self.found.append( + _said( + "unknown-agent", + self.where, + call.lineno, + f"{named} drives {', '.join(sorted(short))}, which this atlas does " + "not -- a supernode is handed the agents of the run around it, by the " + "names it calls them", + ) + ) + return None + return ( + "atlas", + [("agents", THE_AGENTS), ("said", under.takes)], + under.gives, + True, + named, + ) + + def _under(self, call: ast.Call, called: str, named: str) -> Prophecy | None: + """The prophecy one supernode is, compiled where it stands. + + Args: + call: The call, for a finding. + called: The name it calls. + named: The atlas it is, as this flow named it. + + Returns: + The prophecy, or None where compiling it found an error. + """ + beside = self.held.atlases.get(called) + if beside is not None: + read, mark = beside + if self._circular(call, named, _who(read.where, mark.name)): + return None + made, found = _compiled( + self.whole._replace(entered=read, compiled=True), + mark, + named, + self.through, + ) + self.found.extend(found) + return made + from . import ENTRY, find, inside + + at = Path(find(named)) + if self._circular(call, named, _who(at, inside(named))): + return None + held = prophesied( + at.parent if at.name == ENTRY else at, + name=inside(named), + through=self.through, + ) + self.found.extend( + one + for one in held.findings + # A supernode is compiled where it is reached for, so what its own reading + # found is said once. The name it was reached by is what places it. + if one.severity == "error" or one.code != "unsaid-flow" + ) + if held.prophecy is None and not any( + one.severity == "error" for one in held.findings + ): + self.found.append( + _said( + "not-an-atlas", + self.where, + call.lineno, + f"{named} is not an atlas -- an atlas calls an atlas, and reaches an " + "ordinary flow through nothing at all", + ) + ) + return None + return None if held.prophecy is None else held.prophecy._replace(name=named) + + def _circular(self, call: ast.Call, named: str, who: str) -> bool: + """Whether one supernode reaches back into an atlas already being compiled. + + Asked of where the atlas is and what it is called there rather than of the name the + body wrote: one atlas is reached as `deeper` beside it and as `cycle:deeper` from + anywhere else, and a check that compared spellings would follow that forever. + + Args: + call: The call, for a finding. + named: The atlas, as this body named it. + who: Which atlas it is: where it is declared, and what it is called there. + + Returns: + Whether it does, having said so where it does. + """ + if who not in {one for one, _ in self.through}: + return False + self.found.append( + _said( + "circular-atlas", + self.where, + call.lineno, + f"{named} is being compiled already -- a supernode is one graph inside " + f"another, and {' inside '.join((*(said for _, said in self.through), named))}" + " has no bottom", + ) + ) + return True + + def _arguments( + self, call: ast.Call, called: str, takes: _Takes + ) -> list[Reads] | None: + """Where each of one node's arguments comes from, held to what it takes. + + Args: + call: The call. + called: What it calls, for a finding. + takes: The node's parameters as `(name, shape)` pairs. + + Returns: + One per argument, or None where the call does not fit what it calls. + """ + if len(call.args) != len(takes): + self.found.append( + _said( + "shape-mismatch", + self.where, + call.lineno, + f"{called} takes {len(takes)} and is handed {len(call.args)}", + ) + ) + return None + reads: list[Reads] = [] + for one, (param, shape) in zip(call.args, takes, strict=True): + read = self._reads(one) + if read is None: + self._refuse( + one, + "an argument is a name a node bound or a field of one -- " + f"`{_wrote(one)}` is work, and work is what a node is for", + ) + return None + if not self._fits(call, called, param, shape, read): + return None + reads.append(Reads(*read)) + return reads + + def _fits( + self, call: ast.Call, called: str, param: str, shape: str, read: tuple[str, str] + ) -> bool: + """Whether what flows into one parameter is what that parameter takes. + + Args: + call: The call, for a finding. + called: What it calls. + param: The parameter's name. + shape: What it takes, or one of the two agent kinds. + read: The name and field being handed to it. + + Returns: + Whether it fits, having said why where it does not. + """ + if shape in (ONE_AGENT, THE_AGENTS): + return self._agented(call, called, param, shape, read) + given = self._shape_of(read) + if given is None: + self.found.append( + _said( + "unbound-read", + self.where, + call.lineno, + f"nothing here has bound {_names(read)}", + ) + ) + return False + if not given or not _same(given, shape, self.held): + self.found.append( + _said( + "shape-mismatch", + self.where, + call.lineno, + f"{called} takes {param}: {shape}, and {_names(read)} is " + f"{given or _wrote(None)}", + ) + ) + return False + return True + + def _agented( + self, call: ast.Call, called: str, param: str, shape: str, read: tuple[str, str] + ) -> bool: + """Whether what flows into an agent's place is one of the run's own agents. + + Args: + call: The call, for a finding. + called: What it calls. + param: The parameter's name. + shape: Which of the two agent kinds it is. + read: The name and field being handed to it. + + Returns: + Whether it fits, having said why where it does not. + """ + reads, field = read + one = shape == ONE_AGENT + if reads != AGENTS or bool(field) != one: + self.found.append( + _said( + "unagented-node", + self.where, + call.lineno, + f"{called} takes {'one of the agents' if one else 'the agents'} as " + f"{param}, and is handed {_names(read)}", + ) + ) + return False + if one and field not in self.agents: + self.found.append( + _said( + "unknown-agent", + self.where, + call.lineno, + f"this atlas drives {', '.join(self.agents) or 'nothing'}, and " + f"{_names(read)} is none of them", + ) + ) + return False + return True + + # -- the names a body binds and reads -------------------------------------------- + + def _reads(self, node: ast.expr) -> tuple[str, str] | None: + """One name a body reads, and the field read off it. + + Args: + node: The expression. + + Returns: + `(the name, the field)`, the field being "" for the whole of it -- or None where + this is not a name at all. + """ + if isinstance(node, ast.Name): + return (self.names.get(node.id, node.id), "") + if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name): + return (self.names.get(node.value.id, node.value.id), node.attr) + return None + + def _shape_of(self, read: tuple[str, str]) -> str | None: + """What one name, or one field of it, holds. + + Args: + read: The name and the field read off it. + + Returns: + The shape; "" for something bound whose field is no shape an edge may carry; and + None for a name nothing here has bound. + """ + reads, field = read + held = {INPUT: self.takes, CONFIG: self.config}.get(reads) or self.bound.get( + reads + ) + if held is None or reads == AGENTS: + return None + if not field: + return held + model = self.held.models.get(held) + if model is None: + return None + return next( + ( + _shape(one.annotation, self.held) or "" + for one in model.body + if isinstance(one, ast.AnnAssign) + and isinstance(one.target, ast.Name) + and one.target.id == field + ), + None, + ) + + def _binds(self, call: ast.Call, binds: str, gives: str) -> None: + """Binds one name to what the node answered with, once and for the whole body. + + Args: + call: The call, for a finding. + binds: The name. + gives: The shape it now holds. + """ + held = self.bound.get(binds) + if held is not None and held != gives: + self.found.append( + _said( + "shape-mismatch", + self.where, + call.lineno, + f"{binds} is {held} above and {gives or NOTHING} here -- a name keeps " + "the shape it was bound with, so that an edge which fits on the first " + "round fits on every round", + ) + ) + return + self.bound[binds] = gives + + def _id(self, called: str) -> str: + """The node id one more call to the same thing gets.""" + self.seen[called] = at = self.seen.get(called, 0) + 1 + return called if at == 1 else f"{called}:{at}" + + def _refuse(self, node: ast.stmt | ast.expr, said: str) -> None: + """Says one thing the body holds that the subset an atlas is written in does not.""" + self.found.append(_said("unstatic-body", self.where, node.lineno, said)) + + +# --------------------------------------------------------------------------------------- +# The shapes: what may flow along an edge, and whether one fits another. +# --------------------------------------------------------------------------------------- + + +def _shape(annotation: ast.expr | None, held: _Held) -> str | None: + """What one annotation says flows there, by shape name. + + Args: + annotation: The annotation, which may be missing. + held: What the flow's files declare. + + Returns: + The model's name, one of :data:`PLAIN`, `None` for a place that carries nothing, or + None where the annotation is no shape at all. `X | None` is `X`: a shape that may be + missing is the shape, and whether it is there is what a branch reads. + """ + if annotation is None: + return None + if isinstance(annotation, ast.Constant): + if annotation.value is None: + return NOTHING + # A quoted annotation is the annotation: a flow written under `from __future__ + # import annotations` and one written without it declare the same node. + if isinstance(annotation.value, str): + try: + return _shape(ast.parse(annotation.value, mode="eval").body, held) + except SyntaxError: + return None + return None + if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr): + sides = [_shape(annotation.left, held), _shape(annotation.right, held)] + said = [one for one in sides if one is not None and one != NOTHING] + return said[0] if len(said) == 1 and None not in sides else None + if isinstance(annotation, ast.Subscript) and _tip(annotation.value) == "Annotated": + return _shape(_elements(annotation.slice)[0], held) + if not isinstance(annotation, ast.Name): + return None + if annotation.id in held.models or annotation.id in PLAIN: + return annotation.id + return NOTHING if annotation.id == NOTHING else None + + +def _same(given: str, wanted: str, held: _Held) -> bool: + """Whether what one node answers with is what the next one takes. + + The name where both are the same shape, and the fields where they are not: a model that + holds every field another requires, at the same shape apiece, is a model that model can + be built from -- which is what an edge between two of them means. + + Args: + given: The shape flowing in. + wanted: The shape the far end takes. + held: What the flow's files declare. + + Returns: + Whether it fits. + """ + if given == wanted: + return True + one, two = held.models.get(given), held.models.get(wanted) + if one is None or two is None: + return False + holds = {field.name: field.shape for field in _fields(one, held)} + return all( + holds.get(field.name) == field.shape + for field in _fields(two, held) + if field.required + ) + + +def _fields(model: ast.ClassDef, held: _Held) -> list[Field]: + """Every field one model declares, and whether it refuses to be built without it. + + Args: + model: The class. + held: What the flow's files declare, for a base declared beside it. + + Returns: + One per field, the bases' first: a model is what it inherits and what it adds. + """ + said: list[Field] = [] + for base in model.bases: + beside = held.models.get(_root(base)) + if beside is not None and beside is not model: + said.extend(_fields(beside, held)) + for one in model.body: + if not isinstance(one, ast.AnnAssign) or not isinstance(one.target, ast.Name): + continue + name = one.target.id + if name.startswith("_") or _root(one.annotation) == "ClassVar": + continue + said = [was for was in said if was.name != name] + said.append(Field(name, _wrote(one.annotation), required=_required(one.value))) + return said + + +def _required(value: ast.expr | None) -> bool: + """Whether a field with that default refuses to be built without being given one.""" + if value is None: + return True + if isinstance(value, ast.Call) and _tip(value.func) == "Field": + named = {one.arg for one in value.keywords} + return not (value.args or named & {"default", "default_factory"}) + return False + + +def _shapes(carried: set[str], held: _Held) -> list[Shape]: + """Every shape one prophecy carries, written out with the fields each holds. + + Args: + carried: The shape names, as the compiling gathered them. + held: What the flow's files declare. + + Returns: + One per shape, in name order. A plain kind has no fields, having none to have -- and + nor has a model another flow declares, which a supernode's edges name and this flow + cannot read. + """ + return [ + Shape(name, tuple(_fields(model, held)) if model is not None else ()) + for name in sorted(carried) + if name and name != NOTHING + for model in (held.models.get(name),) + ] + + +# --------------------------------------------------------------------------------------- +# The small readings the rules above are written in terms of. +# --------------------------------------------------------------------------------------- + + +def _who(where: Path, name: str) -> str: + """Which atlas one name resolves to: where it is declared, and what it is called there. + + Args: + where: The file it is declared in. + name: What the mark called it inside that file. + + Returns: + The two, as one string -- what a supernode reaching back into a compiling is caught by. + """ + return f"{where.resolve()}::{name}" + + +def _said(code: str, where: Path, line: int, why: str) -> Finding: + """One finding, which for an atlas is always a reason it did not compile.""" + return Finding(code, "error", where, line, why) + + +def _is_docstring(node: ast.stmt, at: int) -> bool: + """Whether one statement is the docstring a body opens with.""" + return ( + at == 0 + and isinstance(node, ast.Expr) + and isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + ) + + +def _is_not(node: ast.expr) -> bool: + """Whether one test is `not` something, which is the branch's other way out.""" + return isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not) + + +def _names(read: tuple[str, str]) -> str: + """How one name and the field read off it read in a finding.""" + reads, field = read + said = { + AGENTS: "the agents", + INPUT: "what the atlas was called with", + CONFIG: "the config", + }.get(reads, reads) + return f"{said}.{field}" if field else said + + +def _wrote(node: ast.expr | ast.stmt | None) -> str: + """One piece of a body as it was written, for a finding to quote back.""" + return "nothing" if node is None else ast.unparse(node) diff --git a/src/hmz/flows/stepping.py b/src/hmz/flows/stepping.py new file mode 100644 index 0000000..e0e0eb4 --- /dev/null +++ b/src/hmz/flows/stepping.py @@ -0,0 +1,464 @@ +"""Running a prophecy: one node at a time, and picking one up where it stopped. + +What an ordinary flow does is whatever its body does, and a run of it that was stopped is a +run that has to start again -- a flow keeps a handful of things in a dict and works out the +rest. An atlas is the other bargain. Its body was compiled before anything ran, so a run of +one is a walk over the prophecy: take the node, run it, write down what it answered, follow +the edge whose guard holds. What a run has done is therefore the list of answers it has, and +picking one up is walking the same prophecy again over the same answers until it reaches the +node that has none. + +Which node that is decides what happens next. By default it runs: a node stopped partway is +work that was not done, and doing it again is the only honest reading of a turn that was cut +off. A node may say otherwise -- `@mind(rerun=False)` -- and is then stepped past, having +already had its effect by the time anything could interrupt it. Such a node answers with +nothing, which is what makes stepping past it possible at all: there is no answer for what +comes next to be missing. + +A run is picked up into the same prophecy or not at all. What was written down is written +down against :func:`~hmz.flows.atlas.digest`, and an atlas rewritten between two runs of it +is a different prophecy whose nodes happen to share their names -- so the digest is checked, +and a run whose prophecy has moved starts from the top rather than resuming into somewhere it +has never been. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +from . import PROPHECY +from .atlas import AGENTS, ATLAS, CONFIG, INPUT, Reads, digest, told + +if TYPE_CHECKING: + import os + from collections.abc import Mapping + + from pydantic import BaseModel + + from .agent import Agent + from .atlas import Edge, Node, Prophecy + from .driving import Entry + +__all__ = ["walking"] + +#: What the state a run writes down keeps: which prophecy it is a run of, which node it was +#: inside when it stopped, and what each node it has finished answered. +_PROPHECY = "prophecy" +_AT = "at" +_DONE = "done" + +#: What one visit to a node is written down under: the node, and how many times the run has +#: been through it -- a loop is one node visited again, and a round whose answer overwrote +#: the last round's would be a run that could not be picked up inside a loop. +_VISIT = "#" + +#: And what a supernode's own nodes are written down under: its visit, then theirs. +_UNDER = "/" + + +def walking( + flow: str | os.PathLike[str], inside: Mapping[str, Any], entry: Entry +) -> Entry: + """Compiles one atlas, and answers with something that runs the prophecy. + + Called where a flow is loaded rather than where it is run, which is what makes an atlas + a flow checked before anything happens: a body that does not compile is a flow refused + at the line that named it, with everything wrong with it said at once. + + Args: + flow: The atlas, as it was asked for -- which is also which of the ones its file holds + is wanted. + inside: What running the flow's file left behind, which is where the nodes are. + entry: The atlas's own entry point, whose body is the declaration that was compiled + and is therefore never called. What it carries is copied onto the answer, so that + everything reading a flow off its entry point goes on reading this one. + + Returns: + Something to call the way any flow is called -- the agents, the task, the config for + one that takes one, and the dict a resumable flow is handed. + + Raises: + NotAFlow: If the atlas does not compile, saying each reason on a line of its own. + """ + from . import ENTRY, Flow, find + from . import inside as which + from .driving import NotAFlow + from .prophesying import prophesied + + at = Path(find(str(flow))) + under = at.parent if at.name == ENTRY else at + wanted = which(str(flow)) or (under.name if at.name == ENTRY else at.stem) + prophecy = _shipped(under, wanted) + if prophecy is None: + held = prophesied(under, name=which(str(flow))) + if held.prophecy is None: + why = "\n".join( + f" {one.where}:{one.line}: {one.code}: {one.said}" + for one in held.findings + if one.severity == "error" + ) + raise NotAFlow(f"{flow}: the atlas does not compile\n{why}") + prophecy = held.prophecy + + def running(agents: Any, task: Any, *said: Any) -> Any: + # A resumable flow is handed its state last and its config before it, and an atlas + # is always resumable: what a run of one has done is which of its nodes answered. + state: dict[str, Any] = said[-1] if said else {} + config = said[0] if len(said) > 1 else None + return _stepped(prophecy, inside, agents, task, config, state) + + marked = getattr(entry, "__humanize_flow__", None) + running.__humanize_flow__ = marked if isinstance(marked, Flow) else Flow() # pyright: ignore[reportFunctionMemberAccess] + setattr(running, ATLAS, getattr(entry, ATLAS, None)) + return running + + +def _shipped(under: Path, wanted: str) -> Prophecy | None: + """The prophecy a flow's own directory ships, where it ships the one being asked for. + + Preferred over compiling the atlas again: the compiling is where an atlas is refused, + and a repository that has been through it has an answer worth carrying. A directory + holds one prophecy and a file may hold several atlases, so the one shipped is the one + it is named after -- and the rest are compiled where they are asked for. + + Args: + under: The flow's own directory. + wanted: Which of the atlases the file holds is being run. + + Returns: + The prophecy, or None where the flow ships none. + + Raises: + NotAFlow: If it ships one that cannot be read back. Refused rather than compiled + again: what a flowverse shipped is what it meant to be run, and quietly running + something else would be the one thing shipping it was meant to rule out. + """ + from .driving import NotAFlow + + at = under / PROPHECY + if not at.is_file(): + return None + held = told(at.read_bytes()) + if held is None: + raise NotAFlow( + f"{at}: the prophecy shipped here cannot be read -- compile the atlas again, " + "or take the file away and let the run compile it" + ) + return held if held.name == wanted else None + + +def _stepped( + prophecy: Prophecy, + inside: Mapping[str, Any], + agents: Any, + given: Any, + config: BaseModel | None, + state: dict[str, Any], +) -> Any: + """Runs one whole atlas, from whatever a run of it has already done. + + Args: + prophecy: The compiled atlas. + inside: What running its file left behind. + agents: The agents, as the atlas declared them. + given: What the atlas was called with -- the task, or the shape a supernode takes. + config: What the run was set up with, or None for an atlas that takes no setting up. + state: What the run before this one wrote down, and what this one writes into. + + Returns: + Whatever the prophecy answers with, and None for one that answers with nothing. + """ + written = digest(prophecy) + if state.get(_PROPHECY) != written: + # A different prophecy: the atlas was rewritten between the two runs, so what the + # last one did, it did somewhere else. Cleared rather than merged, since a node + # that kept its name is not thereby the node it was. + state.clear() + state[_PROPHECY] = written + kept: dict[str, Any] = state.setdefault(_DONE, {}) + return _walked( + prophecy, + inside, + {one: getattr(agents, one) for one in prophecy.agents}, + given, + config, + state, + kept, + "", + ) + + +def _walked( + prophecy: Prophecy, + inside: Mapping[str, Any], + agents: dict[str, Agent], + given: Any, + config: BaseModel | None, + state: dict[str, Any], + kept: dict[str, Any], + under: str, +) -> Any: + """Walks one prophecy from its way in to its way out. + + Args: + prophecy: The compiled atlas, or one of the ones under it. + inside: What running the file it was compiled from left behind. + agents: The run's agents, by the name this prophecy calls each. + given: What it was called with. + config: What the run was set up with. + state: The whole run's state, for where it stopped. + kept: What each node that has answered answered, by visit. + under: What this prophecy's visits are written down beneath, "" for the outermost. + + Returns: + What the prophecy answers with, which is what its last node answered. + """ + bound: dict[str, Any] = {AGENTS: agents, INPUT: given, CONFIG: config} + seen: dict[str, int] = {} + answered: Any = None + at = "" + while True: + edge = _way(prophecy, at, bound) + node = None if edge is None else prophecy.node(edge.into) + if node is None: + return answered + seen[node.at] = visit = seen.get(node.at, 0) + 1 + held = f"{under}{node.at}{_VISIT}{visit}" + answered = _answered( + prophecy, inside, agents, config, state, kept, bound, node, held + ) + if node.binds: + bound[node.binds] = answered + at = node.at + + +def _way(prophecy: Prophecy, at: str, bound: dict[str, Any]) -> Edge | None: + """Which way out of one node this run takes. + + Args: + prophecy: The prophecy. + at: The node it is leaving, and "" for the way in. + bound: What each name holds now. + + Returns: + The edge, or None where this is the end of the run -- either because nothing leads on + from here, or because the way out that does leads out of the prophecy. + """ + for edge in prophecy.out_of(at): + when = edge.when + if ( + when is None + or bool(_read(bound, Reads(when.reads, when.field))) is when.truth + ): + return edge if edge.into else None + return None + + +def _answered( + prophecy: Prophecy, + inside: Mapping[str, Any], + agents: dict[str, Agent], + config: BaseModel | None, + state: dict[str, Any], + kept: dict[str, Any], + bound: dict[str, Any], + node: Node, + held: str, +) -> Any: + """What one node answers with: what it answered last time, or what it answers now. + + Args: + prophecy: The prophecy the node is in. + inside: What running the file it was compiled from left behind. + agents: The run's agents, by name. + config: What the run was set up with. + state: The whole run's state, for where it stopped. + kept: What each node that has answered answered. + bound: What each name holds now. + node: The node. + held: What this visit to it is written down under. + + Returns: + Its answer, rebuilt through the shape it declared where this is a visit the run is + picking up rather than one it is taking. + """ + if held in kept: + return _rebuilt(kept[held], node.gives, inside) + if held == state.get(_AT) and not node.rerun: + # Where the last run stopped, in a node that says it is not to be run again: it had + # its effect before anything could interrupt it, so the run steps past. It answers + # with nothing -- the compiling refuses one that does not -- so there is nothing for + # what comes next to be missing. + kept[held] = None + _saved(state) + return None + state[_AT] = held + _saved(state) + answered = _ran(prophecy, inside, agents, state, kept, bound, node, held) + kept[held] = _written(answered) + _saved(state) + _ = config + return answered + + +def _ran( + prophecy: Prophecy, + inside: Mapping[str, Any], + agents: dict[str, Agent], + state: dict[str, Any], + kept: dict[str, Any], + bound: dict[str, Any], + node: Node, + held: str, +) -> Any: + """Runs one node for real: a turn, a Python function, or a whole prophecy. + + Args: + prophecy: The prophecy the node is in. + inside: What running the file it was compiled from left behind. + agents: The run's agents, by name. + state: The whole run's state. + kept: What each node that has answered answered. + bound: What each name holds now. + node: The node. + held: What this visit to it is written down under. + + Returns: + What it answered. + + Raises: + NotAFlow: If the file the prophecy was compiled from no longer holds what it declared, + which is a flow rewritten under a run of it. + """ + from .driving import NotAFlow + + said = [_read(bound, one) for one in node.takes] + if node.kind == "atlas": + return _supernode(prophecy, inside, agents, state, kept, node, held, said) + call = inside.get(node.calls) + if not callable(call): + raise NotAFlow( + f"{prophecy.name}: nothing in the flow is called {node.calls!r} -- the " + "prophecy was compiled from a file that has since been rewritten" + ) + return call(*said) + + +def _supernode( + prophecy: Prophecy, + inside: Mapping[str, Any], + agents: dict[str, Agent], + state: dict[str, Any], + kept: dict[str, Any], + node: Node, + held: str, + said: list[Any], +) -> Any: + """Runs one supernode, which is a whole prophecy inside this one. + + Args: + prophecy: The prophecy the node is in. + inside: What running the file it was compiled from left behind. + agents: The run's agents, by name. + state: The whole run's state. + kept: What each node that has answered answered. + node: The node. + held: What this visit to it is written down under, which its own nodes go beneath. + said: What it was handed -- the agents, then the one shape it takes. + + Returns: + What the prophecy under it answered with. + + Raises: + NotAFlow: If the prophecy names a supernode it does not hold, which nothing that + compiled should be able to say. + """ + from . import find, loaded + from .driving import NotAFlow + + under = prophecy.under(node.under) + if under is None: + raise NotAFlow(f"{prophecy.name}: nothing under it is called {node.under!r}") + # Beside it or elsewhere: a supernode of this flow's own is in the file this prophecy + # was compiled from, and one reached by name is a flow of its own, run to be read as any + # flow is. Read at the call rather than held, for the reason a called flow is: an atlas + # rewritten between two rounds of a loop is the atlas that runs next. + beside = inside if node.calls in inside else loaded(find(node.calls)) + return _walked( + under, + beside, + {one: agents[one] for one in under.agents}, + said[1], + None, + state, + kept, + f"{held}{_UNDER}", + ) + + +def _read(bound: dict[str, Any], one: Reads) -> Any: + """What one node reads, which is a name a node bound or a field of it. + + Args: + bound: What each name holds now. + one: The reading. + + Returns: + The value. Nothing at all for a name nothing has bound, which the compiling refuses + and which a file rewritten under a run could still produce. + """ + held = bound.get(one.reads) + if not one.field: + return held + # The agents are the one thing a name holds that is not a node's answer, and they are + # held by name: everything else a body binds is a model or a plain kind. + if isinstance(held, dict): + return cast("dict[str, Any]", held).get(one.field) + return getattr(held, one.field, None) + + +def _written(answered: Any) -> Any: + """One node's answer as something a run picked up again can be handed back. + + Args: + answered: What the node answered. + + Returns: + JSON for a model, and the value itself for the plain kinds -- which is the whole of + what a node may answer with, the compiling having refused everything else. + """ + dump = getattr(answered, "model_dump", None) + return dump(mode="json") if callable(dump) else answered + + +def _rebuilt(written: Any, gives: str, inside: Mapping[str, Any]) -> Any: + """One node's kept answer, back in the shape the node declared. + + Args: + written: What was written down. + gives: The shape the node answers with, and "" for one that answers with nothing. + inside: What running the flow's file left behind, which is where the model is. + + Returns: + The value, rebuilt through the model where the flow still declares one -- and as it + was written where it does not, a run picked up into a rewritten flow being one whose + prophecy has already been checked against the digest. + """ + validate = getattr(inside.get(gives), "model_validate", None) + return validate(written) if callable(validate) and written is not None else written + + +def _saved(state: dict[str, Any]) -> None: + """Writes the run's state where it is kept, for a run stopped after this node. + + A `hmz.cycle.State` saves itself as it is written into, and a plain dict is what a flow + run from a test is handed. Both are dicts here, and only one of them has anything to do: + what is written inside `done` is written inside a value the mapping cannot see change. + + Args: + state: What the run is writing down. + """ + save = getattr(state, "save", None) + if callable(save): + save() diff --git a/src/hmz/sdk/flows.py b/src/hmz/sdk/flows.py index 0fd97c0..6c4c94e 100644 --- a/src/hmz/sdk/flows.py +++ b/src/hmz/sdk/flows.py @@ -18,7 +18,7 @@ from pydantic import BaseModel - from hmz.flows import Finding, Flowverse, Offer, Place, Running + from hmz.flows import Finding, Flowverse, Offer, Place, Prophecy, Running __all__ = ["Flows", "Flowverses"] @@ -245,6 +245,58 @@ def check( found.extend(one for one in proof.findings if one.code not in said) return tuple(found) + def prophecy(self, named: str | os.PathLike[str]) -> Prophecy | None: + """What an atlas compiles to, read without running any of it. + + An atlas is a flow whose body is a graph: it is checked and compiled before + anything runs, and what runs is the prophecy that compiling made. This is that + prophecy -- the nodes, the edges, the shapes that flow along them, and one of these + again for every supernode. + + Args: + named: The flow, by the name `-f` takes or by a path. + + Returns: + The prophecy, or None for a flow that is not an atlas or does not compile -- + which :meth:`check` says the reasons for. + """ + from pathlib import Path + + from hmz.flows import ENTRY, find, inside, prophesied + + at = Path(find(str(named))) + whole = at.parent if at.name == ENTRY else at + return prophesied(whole, name=inside(str(named))).prophecy + + def foretell(self, named: str | os.PathLike[str]) -> str: + """Compiles an atlas and writes the prophecy into its own directory. + + What lands is `prophecy.pkl`, which every run of that flow from then on walks + instead of compiling the atlas again: a repository that has been through the + compiling once has an answer worth shipping. `hmz check` says when the file and the + source it came from have drifted apart. + + Args: + named: The flow, by the name `-f` takes or by a path. + + Returns: + Where it was written. + + Raises: + NotAFlow: If it is not an atlas, or does not compile. + """ + from pathlib import Path + + from hmz.flows import ENTRY, PROPHECY, NotAFlow, find, kept + + held = self.prophecy(named) + if held is None: + raise NotAFlow(f"{named}: not an atlas that compiles -- hmz check says why") + at = Path(find(str(named))) + into = (at.parent if at.name == ENTRY else at) / PROPHECY + into.write_bytes(kept(held)) + return str(into) + def configures(self, named: str | os.PathLike[str]) -> type[BaseModel] | None: """What a flow can be set up with, or None for one that takes no setting up.""" from hmz.flows import configures diff --git a/tests/test_prophesying.py b/tests/test_prophesying.py new file mode 100644 index 0000000..723eb7a --- /dev/null +++ b/tests/test_prophesying.py @@ -0,0 +1,453 @@ +"""Compiling an atlas: the narrower Python it is written in, and the prophecy it becomes. + +An ordinary flow is read by running it, and what it will do is nobody's to ask. An atlas +answers that before anything runs: its body is a declaration, this is the reading that holds +it to the subset a declaration is written in, and what comes out is a graph -- nodes, edges, +and the shapes that flow along them. Everything here is refused or compiled without importing +a line of it. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from hmz.flows import PROPHECY, canonical, checked, digest, kept +from hmz.flows.prophesying import Prophesied, prophesied +from tests.stubs import written + +if TYPE_CHECKING: + from pathlib import Path + +#: What every atlas below is written against: who it drives, what flows between its nodes, +#: and the two nodes themselves. The bodies are what differ, which is what is on trial. +HEAD = '''"""An atlas, for the reading to have something to read.""" + +from typing import NamedTuple + +from pydantic import BaseModel, Field + +from hmz.flows import Agent, atlas, logic, mind + + +class Agents(NamedTuple): + """Who it drives.""" + + writer: Agent + + +class Draft(BaseModel): + """What the writer produced.""" + + model_config = {"extra": "forbid"} + + text: str = Field(description="the draft") + + +class Verdict(BaseModel): + """What was made of it.""" + + model_config = {"extra": "forbid"} + + done: bool = Field(description="whether it is finished") + + +@mind +def write(agent: Agent, task: str) -> Draft: + """One turn of writing.""" + return agent(task, shape=Draft) + + +@logic +def judge(said: Draft) -> Verdict: + """Reads the draft, which is what a branch hangs off.""" + return Verdict(done=bool(said.text)) + + +@logic(rerun=False) +def stamp(said: Draft) -> None: + """A node a run picked up again steps past.""" + + +''' + +#: The straight one: a turn, a reading of it, and a loop that ends when the reading says so. +LOOP = '''@atlas +def run(agents: Agents, task: str) -> None: + """Writes until the reading of it says it is done.""" + draft = write(agents.writer, task) + verdict = judge(draft) + while not verdict.done: + draft = write(agents.writer, task) +''' + + +def _held(under: Path, body: str, name: str = "one") -> Prophesied: + """Writes one atlas out and compiles it. + + Args: + under: Where the flows are kept. + body: The body under :data:`HEAD`. + name: What to call the flow. + + Returns: + What compiling it came to. + """ + return prophesied(written(under, name, HEAD + body)) + + +def _codes(under: Path, body: str) -> list[str]: + """Every error one atlas's body is refused for, by code.""" + held = _held(under, body) + assert held.prophecy is None + return [one.code for one in held.findings if one.severity == "error"] + + +def test_a_body_compiles_to_the_graph_it_declares(tmp_path: Path) -> None: + """One node per call, one edge per way from one to the next, and nothing run.""" + held = _held(tmp_path, LOOP) + + assert held.findings == () + prophecy = held.prophecy + assert prophecy is not None + assert [one.at for one in prophecy.nodes] == ["write", "judge", "write:2"] + assert [one.kind for one in prophecy.nodes] == ["mind", "logic", "mind"] + assert prophecy.agents == ("writer",) + assert prophecy.takes == "str" + + +def test_a_loop_is_an_edge_back_to_the_node_the_branch_reads(tmp_path: Path) -> None: + """Which is what makes the head answer again with whatever the round changed.""" + prophecy = _held(tmp_path, LOOP).prophecy + assert prophecy is not None + + ways = {(one.out_of, one.into): one.when for one in prophecy.edges} + assert ways[("", "write")] is None # the way in + assert ways[("write", "judge")] is None + rounds = ways[("judge", "write:2")] + assert rounds is not None + assert rounds.truth is False # round again while the reading says it is not done + assert ways[("write:2", "judge")] is None # and back to the node that reads it + over = ways[("judge", "")] + assert over is not None + assert over.truth is True # and out of the graph when it is + + +def test_the_same_body_written_twice_compiles_to_the_same_bytes(tmp_path: Path) -> None: + """Canonical means what it says: a comment is not part of what the atlas is.""" + one = _held(tmp_path, LOOP, "one").prophecy + two = _held( + tmp_path, + LOOP.replace( + " draft = write", " # a comment nobody compiles\n draft = write", 1 + ), + "two", + ).prophecy + assert one is not None + assert two is not None + + assert canonical(one) == canonical(two._replace(name="one")) + assert digest(one) == digest(two._replace(name="one")) + + +def test_what_a_node_answers_with_is_written_down_field_by_field( + tmp_path: Path, +) -> None: + """A shape is what an edge carries, and what carries it is what both ends are held to.""" + prophecy = _held(tmp_path, LOOP).prophecy + assert prophecy is not None + + said = json.loads(canonical(prophecy)) + assert {one["name"] for one in said["shapes"]} == {"Draft", "Verdict", "str"} + verdict = next(one for one in said["shapes"] if one["name"] == "Verdict") + assert verdict["fields"] == [["done", "bool", True]] + + +@pytest.mark.parametrize( + ("why", "body", "code"), + [ + ( + "a turn has one way out", + '''@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + if draft.text: + draft = write(agents.writer, task) +''', + "branching-mind", + ), + ( + "work is what a node is for", + '''@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + said = draft.text + "!" +''', + "unstatic-body", + ), + ( + "what flows in is what the far end takes", + '''@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + again = write(agents.writer, draft) +''', + "shape-mismatch", + ), + ( + "an agent it does not drive", + '''@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.reviewer, task) +''', + "unknown-agent", + ), + ( + "a loop nothing inside can end", + '''@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + verdict = judge(draft) + while not verdict.done: + pass +''', + "dead-loop", + ), + ( + "a node stepped past has no answer to leave behind", + '''@logic(rerun=False) +def marked(said: Draft) -> Verdict: + """Answers, and says it is stepped past.""" + return Verdict(done=True) + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + verdict = marked(draft) +''', + "skipped-answer", + ), + ( + "a name nothing bound", + '''@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + verdict = judge(nowhere) +''', + "unbound-read", + ), + ( + "a plain tuple says only how many", + '''@atlas +def run(agents: tuple[Agent], task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) +''', + "unnamed-agents", + ), + ( + "two decisions carried on one edge", + '''@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + verdict = judge(draft) + if verdict.done: + draft = write(agents.writer, task) + elif verdict.done: + draft = write(agents.writer, task) +''', + "unstatic-body", + ), + ( + "a name keeps the shape it was bound with", + '''@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + draft = judge(draft) +''', + "shape-mismatch", + ), + ( + "a graph with no nodes", + '''@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" +''', + "unstatic-body", + ), + ( + "a node that says nothing about what flows through it", + '''@logic +def loose(said): + """Says nothing.""" + return said + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + said = loose(task) +''', + "unshaped-node", + ), + ( + "a logic handed an agent", + '''@logic +def turned(agent: Agent, said: Draft) -> Verdict: + """Takes one, and is not a turn.""" + return Verdict(done=True) + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + verdict = turned(agents.writer, draft) +''', + "unagented-node", + ), + ], +) +def test_the_body_an_atlas_may_not_hold( + tmp_path: Path, why: str, body: str, code: str +) -> None: + """Every one of these is decidable, which is the bargain an atlas makes.""" + assert code in _codes(tmp_path, body), why + + +def test_an_atlas_reaches_an_atlas_and_nothing_else(tmp_path: Path) -> None: + """`load` answers with a flow that may be anything, which is a hole in a graph.""" + body = '''from hmz.flows import load + +chat = load("chat") + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) +''' + assert "dynamic-call" in _codes(tmp_path, body) + + +def test_a_supernode_is_the_atlas_under_it_compiled(tmp_path: Path) -> None: + """One node from outside, one prophecy from within.""" + body = '''@atlas(name="inner") +def inner(agents: Agents, said: Draft) -> Verdict: + """A whole atlas, reached as one node.""" + verdict = judge(said) + return verdict + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + verdict = inner(agents, draft) +''' + prophecy = _held(tmp_path, body).prophecy + assert prophecy is not None + + node = prophecy.node("inner") + assert node is not None + assert node.kind == "atlas" + assert node.under == "inner" + under = prophecy.under("inner") + assert under is not None + assert [one.at for one in under.nodes] == ["judge"] + assert under.takes == "Draft" + assert under.gives == "Verdict" + + +def test_a_supernode_that_reaches_back_into_its_own_graph_is_refused( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A graph inside a graph inside itself has no bottom, however it is spelled. + + Reached by name here rather than beside it, since the two spellings of one atlas are + exactly what a check comparing names would follow forever. + """ + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.chdir(tmp_path) + body = '''from hmz.flows import sub + +again = sub("one:inner") + + +@atlas(name="inner") +def inner(agents: Agents, said: Draft) -> Verdict: + """Reaches back into itself.""" + verdict = again(agents, said) + return verdict + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + verdict = inner(agents, draft) +''' + assert "circular-atlas" in _codes(tmp_path / ".humanize/flows", body) + + +def test_a_flow_that_is_not_an_atlas_is_not_compiled(tmp_path: Path) -> None: + """`checked` is the reading for those, and it is what it goes on being.""" + plain = '''"""An ordinary flow.""" + +from hmz.flows import Agent, flow + + +@flow +def run(agents: tuple[Agent], task: str) -> None: + """Does whatever it likes.""" +''' + at = written(tmp_path, "plain", plain) + + assert prophesied(at).prophecy is None + assert [one.code for one in prophesied(at).findings] == ["not-an-atlas"] + assert checked(at) == () # and the ordinary reading has nothing against it + + +def test_the_stricter_reading_is_the_one_an_atlas_gets(tmp_path: Path) -> None: + """`hmz check` asks one question, and an atlas is what decides which reading answers.""" + body = '''@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + if draft.text: + draft = write(agents.writer, task) +''' + at = written(tmp_path, "one", HEAD + body) + + assert "branching-mind" in {one.code for one in checked(at)} + + +def test_a_shipped_prophecy_that_is_no_longer_the_source_is_said( + tmp_path: Path, +) -> None: + """A run walks the shipped one, so a drifted flow reads as something it is not.""" + at = written(tmp_path, "one", HEAD + LOOP) + held = prophesied(at).prophecy + assert held is not None + (at / PROPHECY).write_bytes(kept(held._replace(gives="Draft"))) + + assert "stale-prophecy" in {one.code for one in checked(at)} + + +def test_a_prophecy_that_cannot_be_read_back_is_not_walked(tmp_path: Path) -> None: + """Bytes that are not a prophecy are a file to compile again, not a graph to guess at.""" + at = written(tmp_path, "one", HEAD + LOOP) + (at / PROPHECY).write_bytes(b"nothing here is a prophecy") + + assert "stale-prophecy" in {one.code for one in checked(at)} diff --git a/tests/test_stepping.py b/tests/test_stepping.py new file mode 100644 index 0000000..f88d9e0 --- /dev/null +++ b/tests/test_stepping.py @@ -0,0 +1,419 @@ +"""Running an atlas: the prophecy walked a node at a time, and picked up where it stopped. + +An atlas's body is never run. What runs is the graph compiling it made, which is what puts a +run in a position to be stopped and started: the answers are written down as they arrive, so +picking a run up is walking the same graph over the same answers until it reaches the node +that has none. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from hmz.agents import AgentConfig +from hmz.cycle import cycles, state +from hmz.flows import PROPHECY, NotAFlow, kept, resumes +from hmz.flows.prophesying import prophesied +from hmz.runner import Runner +from hmz.sdk import Hmz +from tests.stubs import ShellAgent, written + +if TYPE_CHECKING: + from pathlib import Path + +CONFIG = AgentConfig(model="m", effort="high") + +#: An atlas of three nodes that says which of them ran, and can be made to stop in the last. +THREE = '''"""Three nodes, and a file that says which of them ran.""" + +from pathlib import Path +from typing import NamedTuple + +from pydantic import BaseModel, Field + +from hmz.flows import Agent, atlas, logic, mind + + +class Agents(NamedTuple): + """Who it drives.""" + + writer: Agent + + +class Said(BaseModel): + """What flows between them.""" + + model_config = {"extra": "forbid"} + + text: str = Field(description="what the node had to say") + + +def ran(name: str) -> None: + """Writes down that one node ran, since a run is what a test is watching.""" + at = Path("ran.txt") + at.write_text((at.read_text() if at.exists() else "") + name + " ") + + +@mind +def first(agent: Agent, task: str) -> Said: + """One turn, and the only one.""" + ran("first") + agent.new()("true") + return Said(text=task) + + +@logic +def middle(said: Said) -> Said: + """Reads it, and is kept.""" + ran("middle") + return said + + +@logic(rerun=RERUN) +def last(said: Said) -> None: + """Stops the first run of it, and says whether a run picked up runs it again.""" + ran("last") + if not Path("been.txt").exists(): + Path("been.txt").write_text("yes") + raise RuntimeError("stopped") + + +@atlas +def run(agents: Agents, task: str) -> None: + """Runs the three of them.""" + said = first(agents.writer, task) + held = middle(said) + last(held) +''' + +#: One that loops, so that a node visited twice is two answers rather than one overwritten. +ROUNDS = '''"""Writes until the reading of it says three rounds have been.""" + +from pathlib import Path +from typing import NamedTuple + +from pydantic import BaseModel, Field + +from hmz.flows import Agent, atlas, logic, mind + + +class Agents(NamedTuple): + """Who it drives.""" + + writer: Agent + + +class Draft(BaseModel): + """What the writer produced, and on which round.""" + + model_config = {"extra": "forbid"} + + text: str = Field(description="the draft") + round: int = Field(default=0, description="which round wrote it") + + +class Verdict(BaseModel): + """Whether the loop is over.""" + + model_config = {"extra": "forbid"} + + done: bool = Field(description="whether three rounds have been") + + +@mind +def write(agent: Agent, task: str) -> Draft: + """One round of it.""" + at = Path("rounds.txt") + said = at.read_text() if at.exists() else "" + at.write_text(said + "w") + agent.new()("true") + return Draft(text=task, round=len(said) + 1) + + +@logic +def judge(said: Draft) -> Verdict: + """Three rounds and it is done.""" + return Verdict(done=said.round >= 3) + + +@atlas +def run(agents: Agents, task: str) -> None: + """Rounds until it is done.""" + draft = write(agents.writer, task) + verdict = judge(draft) + while not verdict.done: + draft = write(agents.writer, task) +''' + +#: And one whose node is a whole atlas of its own, reached beside it and by name. +INNER = '''"""The atlas that is reached by name.""" + +from pathlib import Path +from typing import NamedTuple + +from pydantic import BaseModel, Field + +from hmz.flows import Agent, atlas, mind + + +class Agents(NamedTuple): + """Who it drives.""" + + writer: Agent + + +class Said(BaseModel): + """What flows through.""" + + model_config = {"extra": "forbid"} + + text: str = Field(description="the text") + + +@mind +def deepen(agent: Agent, said: Said) -> Said: + """One turn, inside a node that is a graph.""" + agent.new()("true") + at = Path("ran.txt") + at.write_text((at.read_text() if at.exists() else "") + "deepen ") + return Said(text=said.text + "!") + + +@atlas +def run(agents: Agents, said: Said) -> Said: + """One node, and it is a turn.""" + out = deepen(agents.writer, said) + return out +''' + +OUTER = '''"""The atlas with a supernode in it, twice over.""" + +from pathlib import Path +from typing import NamedTuple + +from pydantic import BaseModel, Field + +from hmz.flows import Agent, atlas, logic, sub + + +class Agents(NamedTuple): + """Who it drives.""" + + writer: Agent + + +class Said(BaseModel): + """What flows through.""" + + model_config = {"extra": "forbid"} + + text: str = Field(description="the text") + + +deeper = sub("inner") + + +@logic +def start(task: str) -> Said: + """Opens it.""" + Path("ran.txt").write_text("start ") + return Said(text=task) + + +@atlas(name="twice") +def beside(agents: Agents, said: Said) -> Said: + """A supernode of this file's own, holding one of another file's.""" + once = deeper(agents, said) + return once + + +@logic +def finish(said: Said) -> None: + """Writes what came back out.""" + Path("out.txt").write_text(said.text) + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + said = start(task) + held = beside(agents, said) + finish(held) +''' + + +@pytest.fixture(autouse=True) +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A project with atlases of its own, and a home nothing wrote to.""" + monkeypatch.setenv("HOME", str(tmp_path / "home")) + where = tmp_path / "project" + (where / ".humanize/flows").mkdir(parents=True) + monkeypatch.chdir(where) + return where + + +def _write(project: Path, name: str, source: str) -> Path: + """Writes one atlas into this project's own flows.""" + return written(project / ".humanize/flows", name, source) + + +def _run(name: str, task: str = "go") -> None: + """Runs one atlas with a stand-in agent, the way a command line runs any flow.""" + Runner(name, [ShellAgent(CONFIG)]).run(task) + + +def test_an_atlas_runs_its_prophecy_rather_than_its_body(project: Path) -> None: + """The body is a declaration, and each node in it is called by the walk over the graph.""" + _write(project, "three", THREE.replace("RERUN", "True")) + + with pytest.raises(RuntimeError): + _run("three") + + assert (project / "ran.txt").read_text() == "first middle last " + + +def test_an_atlas_says_it_can_be_picked_up_without_being_asked(project: Path) -> None: + """A graph is a list of nodes with an answer apiece, so a run of one writes itself down.""" + _write(project, "three", THREE.replace("RERUN", "True")) + + assert resumes("three") is True + + +def test_what_a_run_has_done_is_the_answers_it_has(project: Path) -> None: + """One line per visit to a node, which is what picking the run up walks over again.""" + _write(project, "rounds", ROUNDS) + + _run("rounds") + + held = state(cycles()[-1], "rounds") + assert (project / "rounds.txt").read_text() == "www" + # A node visited twice is two answers: a round that overwrote the last round's would be + # a run nothing could be picked up in the middle of. + assert sorted(held["done"]) == [ + "judge#1", + "judge#2", + "judge#3", + "write#1", + "write:2#1", + "write:2#2", + ] + assert held["done"]["write:2#2"] == {"text": "go", "round": 3} + + +def test_the_node_a_run_stopped_inside_is_run_again(project: Path) -> None: + """Work cut off partway was not done, which is what a node says by saying nothing.""" + _write(project, "three", THREE.replace("RERUN", "True")) + with pytest.raises(RuntimeError): + _run("three") + (project / "ran.txt").write_text("") + + _run("three") + + # The two above it answered already, and are picked up rather than taken again. + assert (project / "ran.txt").read_text() == "last " + + +def test_a_node_may_say_it_is_stepped_past_instead(project: Path) -> None: + """One that had its effect before anything could interrupt it, and answers with nothing.""" + _write(project, "three", THREE.replace("RERUN", "False")) + with pytest.raises(RuntimeError): + _run("three") + (project / "ran.txt").write_text("") + + _run("three") + + assert (project / "ran.txt").read_text() == "" + + +def test_a_run_is_picked_up_into_the_same_graph_or_not_at_all(project: Path) -> None: + """An atlas rewritten is a different graph whose nodes happen to share their names.""" + at = _write(project, "three", THREE.replace("RERUN", "True")) + with pytest.raises(RuntimeError): + _run("three") + at.joinpath("__init__.py").write_text( + THREE.replace("RERUN", "True").replace( + " held = middle(said)", + " said = first(agents.writer, task)\n held = middle(said)", + ) + ) + (project / "ran.txt").write_text("") + + _run("three") + + assert (project / "ran.txt").read_text() == "first first middle last " + + +def test_a_supernode_is_the_graph_under_it_walked(project: Path) -> None: + """One node from outside, one run from within, written down beneath the node it is.""" + _write(project, "inner", INNER) + _write(project, "outer", OUTER) + + _run("outer", "hello") + + assert (project / "ran.txt").read_text() == "start deepen " + assert (project / "out.txt").read_text() == "hello!" + assert sorted(state(cycles()[-1], "outer")["done"]) == [ + "beside#1", + "beside#1/deeper#1", + "beside#1/deeper#1/deepen#1", + "finish#1", + "start#1", + ] + + +def test_a_body_that_does_not_compile_is_refused_where_it_is_named( + project: Path, +) -> None: + """Before the first turn rather than hours in, which is what an atlas is for.""" + _write( + project, + "three", + THREE.replace("RERUN", "True").replace(" last(held)", " last(said.text)"), + ) + + with pytest.raises(NotAFlow, match="does not compile"): + _run("three") + + +def test_the_prophecy_a_flowverse_ships_is_the_one_that_runs(project: Path) -> None: + """A repository that has been through the compiling has an answer worth carrying.""" + at = _write(project, "three", THREE.replace("RERUN", "True")) + held = prophesied(at).prophecy + assert held is not None + at.joinpath(PROPHECY).write_bytes(kept(held)) + # The source now says one node more, and the shipped graph says it does not. + at.joinpath("__init__.py").write_text( + THREE.replace("RERUN", "True").replace( + " last(held)", " last(held)\n last(held)" + ) + ) + + with pytest.raises(RuntimeError): + _run("three") + + assert (project / "ran.txt").read_text() == "first middle last " + + +def test_a_shipped_prophecy_that_cannot_be_read_is_refused(project: Path) -> None: + """What a flowverse shipped is what it meant to run, so nothing else quietly runs.""" + at = _write(project, "three", THREE.replace("RERUN", "True")) + at.joinpath(PROPHECY).write_bytes(b"nothing here is a prophecy") + + with pytest.raises(NotAFlow, match="cannot be read"): + _run("three") + + +def test_one_is_shipped_and_read_back_through_the_sdk(project: Path) -> None: + """Which is the one call anything that compiles an atlas makes.""" + _write(project, "rounds", ROUNDS) + flows = Hmz().flows + + where = flows.foretell("rounds") + + assert where.endswith(PROPHECY) + said = flows.prophecy("rounds") + assert said is not None + assert [one.at for one in said.nodes] == ["write", "judge", "write:2"] + assert flows.check("rounds") == () From 636020110f40020e8f5de3603b951cc918f84a73 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:24:16 +0000 Subject: [PATCH 10/18] feat(flows): let a flowverse ship the prophecy its atlas compiled to `prophecy.pkl` beside a flow's entry point is what runs, rather than the atlas compiled again: the compiling is where an atlas is refused, and a repository that has been through it once has an answer worth carrying. The flow's own Python still has to be there -- a prophecy names the functions its nodes are -- so a directory holding a prophecy and nothing else is not a flow. `hmz check` says when a shipped prophecy and the source it came from have drifted apart, since a run walks the shipped one; `--prophecy` prints what an atlas compiles to and `--ship` writes it beside the flow. `Hmz().flows` answers both. Co-Authored-By: Claude Opus 5 (1M context) --- docs/.vitepress/config.mts | 1 + docs/guide/atlas.md | 280 +++++++++++++++++++++++++++++++++++ docs/guide/checking-flows.md | 11 ++ docs/guide/concepts.md | 26 ++++ docs/guide/resuming.md | 7 + docs/guide/writing-a-flow.md | 9 ++ docs/reference/cli.md | 12 +- docs/reference/flows.md | 107 +++++++++++++ docs/reference/sdk.md | 2 + src/hmz/SPEC.md | 7 +- src/hmz/flows/SPEC.md | 273 ++++++++++++++++++++++++++++++++++ src/hmz/flows/prophesying.py | 13 +- src/hmz/sdk/SPEC.md | 6 + 13 files changed, 745 insertions(+), 9 deletions(-) create mode 100644 docs/guide/atlas.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5793ea7..e411d0e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -223,6 +223,7 @@ export default defineConfig({ { text: 'Settings of its own', link: '/guide/flow-settings' }, { text: 'Many turns at once', link: '/guide/async-flows' }, { text: 'A flow that calls a flow', link: '/guide/calling-flows' }, + { text: 'An atlas', link: '/guide/atlas' }, { text: 'Checking a flow', link: '/guide/checking-flows' }, { text: 'Testing a flow', link: '/guide/testing-flows' }, { text: 'Flowverses', link: '/guide/flowverses' }, diff --git a/docs/guide/atlas.md b/docs/guide/atlas.md new file mode 100644 index 0000000..f828410 --- /dev/null +++ b/docs/guide/atlas.md @@ -0,0 +1,280 @@ +# An atlas + +A flow is a Python file, and the one thing nothing can ask it is what it is about to do. An +atlas is the other bargain: a narrower Python whose body is read rather than run, compiled +before anything happens into a graph called a **prophecy** — the nodes, the edges between +them, and the shapes that flow along each edge. + +What you get for the narrowness is everything a graph makes possible. The flow is checked +whole before its first turn, not hours in. What it will do can be printed, diffed and +reviewed. And a run of one can be stopped and picked up in the middle, because a graph is a +list of nodes with an answer apiece and the run writes those answers down as they arrive. + +## One that writes and reviews + +```python +"""Writes a draft, reviews it, and goes round until the review says it is done.""" + +from typing import NamedTuple + +from pydantic import BaseModel, Field + +from hmz.flows import Agent, atlas, logic, mind + + +class Agents(NamedTuple): + """Who this drives.""" + + writer: Agent + reviewer: Agent + + +class Draft(BaseModel): + """What the writer produced.""" + + model_config = {"extra": "forbid"} + + text: str = Field(description="the draft itself") + + +class Verdict(BaseModel): + """What the reviewer made of it.""" + + model_config = {"extra": "forbid"} + + done: bool = Field(description="whether the work is finished") + notes: str = Field(default="", description="what to fix next") + + +@mind +def write(agent: Agent, task: str) -> Draft: + """One turn of writing.""" + return agent(task, shape=Draft) + + +@mind +def review(agent: Agent, draft: Draft) -> Verdict: + """One turn of reviewing.""" + return agent(f"review this:\n\n{draft.text}", shape=Verdict) + + +@logic +def settled(said: Verdict) -> Verdict: + """Reads the review, which is what the loop branches on.""" + return said + + +@atlas +def run(agents: Agents, task: str) -> None: + """Writes and reviews until the review says it is done.""" + draft = write(agents.writer, task) + seen = review(agents.reviewer, draft) + verdict = settled(seen) + while not verdict.done: + draft = write(agents.writer, task) + seen = review(agents.reviewer, draft) +``` + +It is run exactly as any other flow is: + +```sh +hmz exec -f review_loop -a writer=claude/sonnet:high -a reviewer=codex/gpt-5.6-sol "$(cat TASK.md)" +``` + +## The two kinds of node + +A **mind** is a turn: real work by a real agent, handed the agent the call site names. A +**logic** is a Python function: no agent, no turn, and a decision anything can read. + +A mind has exactly one way out; a logic may have several. That is the whole of why the two +are told apart. A branch is a decision, and a decision nothing but a model made is a decision +no reading of the flow can state — so the node a branch hangs off is a logic node, and what a +model said reaches a branch by being read by one. Writing the `if` straight off the turn is +refused: + +``` +…/__init__.py:71: error: branching-mind: review is a turn, and a turn has one way out -- +read what it answered with a logic node, and branch on that +``` + +That is what `settled` is for above. In a real flow it would earn its keep — counting the +rounds, holding the loop to a budget, deciding that three passes is enough however the +reviewer feels about it. + +## The Python an atlas is written in + +The body of an atlas is a declaration, and holds only these: + +| | | +| --- | --- | +| `x = call(a, b)` | one node, whose answer is bound to `x` | +| `call(a, b)` | one node whose answer nothing takes | +| `if x:` / `if not x.field:` | a node's several ways out | +| `while x:` / `while not x.field:` | that, with an edge back to the node the test reads | +| `return` / `return x` | where the run ends | +| `pass`, the docstring | nothing at all | + +Arguments are names the body has bound, fields read off them, or one of the flow's own three: +`agents`, what it was called with, and — for an atlas that takes one — its config. + +Everything else is refused, because everything else is a thing a node does: + +```python +n = draft.round + 1 # unstatic-body: work is what a logic node is for +said = write(agents.writer, judge(draft)) # a call inside a call is two nodes +if verdict.done and enough: # a compound test is a decision a logic node makes +``` + +The rest of the file is ordinary Python. Only the `@atlas` body is narrowed — a mind or a +logic may do whatever a Python function may do, and is read by the same rules as any other +flow's code. + +## What flows between nodes + +Every node says what it takes and what it answers with, and both are pydantic models the flow +declares or one of `str`, `int`, `float`, `bool`. Each edge is checked before anything runs: + +``` +…/__init__.py:70: error: shape-mismatch: write takes task: str, and draft is Draft +``` + +A model may flow into a parameter of another model's type when it holds every field that one +requires, at the same shape apiece. A name keeps the shape it was first bound with, so an edge +that fits on the first round of a loop fits on every round. + +## Loops + +The node above a `while` is its **head**: it answers the name the test reads, the body runs +while that holds, and the body's last node wires back to the head — which answers again with +whatever the round changed. So the loop above reads as: + +``` +write → review → settled ──[done]──▶ (end) + │ ▲ + [not done] │ │ + ▼ │ + write:2 → review:2 +``` + +A loop whose body changes nothing the head reads would answer the same thing every round, and +is refused as a `dead-loop` rather than run for a week. + +## Stopping and starting + +An atlas can always be picked up where the last run of it left off, and says so without being +asked. Every node's answer is written into the run's state as it arrives: + +```json +{ + "prophecy": "06ebb726641e4a5f", + "at": "review:2#3", + "done": {"write#1": {"text": "…"}, "settled#1": {"done": false, "notes": "…"}} +} +``` + +Picking the run up walks the same graph over the same answers until it reaches the visit that +has none. That node **runs again** — work cut off partway is work that was not done. A node +that has already had its effect by the time anything could interrupt it can say otherwise: + +```python +@logic(rerun=False) +def announce(said: Verdict) -> None: + """Says it once, and is stepped past rather than said twice.""" + post_to_slack(said.notes) +``` + +Such a node answers with nothing — the compiling refuses one that does not, since a run +stepping past it would have no answer for what comes next. + +A run is picked up into the same graph or not at all. What was written down is written down +against the prophecy's digest, so an atlas rewritten between two runs starts from the top +rather than resuming into somewhere it has never been. Rewriting a node's *body* changes +nothing about the graph, and such a run picks up as it should. + +## An atlas inside an atlas + +A node may be a whole atlas — a **supernode**. One beside it is called by name: + +```python +@atlas(name="review", selectable=False) +def reviewing(agents: Agents, draft: Draft) -> Verdict: + """A graph of its own, reached as one node.""" + seen = review(agents.reviewer, draft) + return settled(seen) + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + verdict = reviewing(agents, draft) +``` + +and one in another flow is named with `sub`, which is the counterpart of +[`load`](/guide/calling-flows): + +```python +reviewing = sub("official/review") +``` + +An atlas reaches an atlas and reaches an ordinary flow through nothing at all: `load` answers +with a flow that may be anything, and a graph with one of those in it is a graph with a hole +where a node should be. Importing `load` in an atlas is a `dynamic-call` error. + +A supernode's own nodes are written down beneath the node it is, so a run stopped three graphs +deep is picked up three graphs deep. A supernode that reaches back into a graph already being +compiled is refused, however it is spelled. + +## Reading what it compiles to + +```sh +hmz check --prophecy review_loop +``` + +prints the canonical prophecy — one line of JSON, everything ordered by what it is rather than +where it was written, so two readings of the same atlas are the same bytes and a diff of two +prophecies is a diff of two graphs. `hmz check` on an atlas is the stricter reading +automatically: + +```sh +hmz check review_loop +``` + +## Shipping the prophecy + +A flowverse may ship what compiling came to, beside the flow: + +```sh +hmz check --ship official/review # writes official/review/prophecy.pkl +``` + +Where there is one, that is what runs. The compiling is where an atlas is refused, and a +repository that has been through it once has an answer worth carrying rather than working out +again at every run. The flow's own Python still has to be there — a prophecy names the +functions its nodes are. + +`hmz check` says when a shipped prophecy and the source it came from have drifted apart: + +``` +…/prophecy.pkl:0: error: stale-prophecy: the prophecy shipped here is d1f27db7dffd22e3 and +this source compiles to 4c9a01ab2f7e5510 -- a run walks the shipped one, so the flow does one +thing and reads as another +``` + +::: warning +A shipped prophecy is a pickle, and reading one runs what it says. That is the trust a +flowverse already has — [a flow is a directory of Python and reading one means running +it](/guide/security) — but it is worth knowing that `prophecy.pkl` is code and not data. +::: + +## When to write one, and when not to + +An atlas is worth it when the shape of the work is known and the run is long: a pipeline of +phases, a review loop meant to run for a week, anything a machine going down should not send +back to the start. It is worth it when somebody other than its author has to be able to say +what the flow will do before it does it. + +An ordinary [flow](/guide/writing-a-flow) is right for everything else — a loop that decides +its own shape as it goes, a flow that fans out over whatever it found, one that is thirty +lines and does one thing. Nothing here replaces that; the two live side by side, are named the +same way, and are run by the same line. diff --git a/docs/guide/checking-flows.md b/docs/guide/checking-flows.md index 484aedc..26a96a5 100644 --- a/docs/guide/checking-flows.md +++ b/docs/guide/checking-flows.md @@ -79,3 +79,14 @@ hmz check --json local/mine | jq -r .code - [Checking a flow](/reference/flows#checking-a-flow) — the library API and the rule table - [Testing a flow](/guide/testing-flows) — driving a flow with stand-ins of your own - [Writing a flow](/guide/writing-a-flow) + +## An atlas is read more strictly + +A flow marked [`@atlas`](/guide/atlas) gets the stricter of the two readings automatically: +its body is a declaration rather than a program, so `hmz check` compiles it and holds every +edge, every branch and every shape to what a graph can be held to. `--prophecy` prints the +graph it compiled; `--ship` writes it beside the flow for runs of it to walk. + +```sh +hmz check --prophecy local/mine +``` diff --git a/docs/guide/concepts.md b/docs/guide/concepts.md index d976a56..1185a93 100644 --- a/docs/guide/concepts.md +++ b/docs/guide/concepts.md @@ -135,6 +135,32 @@ thing to write and three to run. Each asks only for the agents it drives. See [Flows](/reference/flows). +## Atlas + +**A flow whose body is read rather than run.** Marked `@atlas` rather than `@flow`, written in +a narrower Python, and compiled before anything happens into a graph — a **prophecy** — of the +nodes the run will take and the edges between them. + +```python +@atlas +def run(agents: Agents, task: str) -> None: + draft = write(agents.writer, task) + verdict = judge(draft) + while not verdict.done: + draft = write(agents.writer, task) +``` + +Each statement is one node. A `@mind` is one turn by one agent and has exactly one way out; a +`@logic` is a Python function and may have several, which is what a branch hangs off. What +flows between them is a pydantic model, checked edge by edge before the first turn. An atlas +called by an atlas is one node of the graph around it. + +An atlas is a flow in every other way — found, listed, named and run by the same line. What it +buys is that its shape is known in advance: it can be printed and diffed, it is checked whole +before it starts, and a run of one is picked up node by node rather than started again. + +See [An atlas](/guide/atlas). + ## Flowverse **A git repository with a `flows/` directory in it.** One directory per flow holds an diff --git a/docs/guide/resuming.md b/docs/guide/resuming.md index be90920..8207f1b 100644 --- a/docs/guide/resuming.md +++ b/docs/guide/resuming.md @@ -171,6 +171,13 @@ This is what makes a week of stops and starts readable afterwards. Each stretch its own sessions, its own trace and its own end, rather than one enormous cycle claiming to have begun on Monday. +## An atlas picks itself up + +A flow keeps what it wants to carry by hand, which is the whole of this page. An +[atlas](/guide/atlas) does not have to: its body is compiled into a graph, every node's answer +is written down as it arrives, and picking a run up is walking that graph over the answers it +already has until it reaches the node that has none. + ## See also - [Tracing](/guide/tracing) — what else a run writes down, and reading one back diff --git a/docs/guide/writing-a-flow.md b/docs/guide/writing-a-flow.md index 89974f1..3eb37b6 100644 --- a/docs/guide/writing-a-flow.md +++ b/docs/guide/writing-a-flow.md @@ -192,8 +192,17 @@ from hmz.flows import drives drives("twice") # the names of the agents it declares ``` +## A flow whose shape is known before it runs + +Everything above is a flow: a Python file that may branch any way it likes, and whose shape is +whatever it does. Where the shape is known in advance — a pipeline of phases, a review loop +meant to run for a week — an [atlas](/guide/atlas) is the stricter bargain. Its body is a +declaration rather than a program, compiled into a graph before the first turn, so it is +checked whole up front and a run of one is picked up node by node rather than started again. + ## See also +- [An atlas](/guide/atlas) - [Read the run back](/guide/tracing) - [Flowverses](/guide/flowverses) - [Loops](/guide/loops) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a4f623e..99bb041 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -280,7 +280,7 @@ hmz anchor serve --listen 0.0.0.0:7777 --export /srv/project --token "$SECRET" ## `hmz check` ``` -hmz check [--static] [--strict] [--json] FLOW [FLOW...] +hmz check [--static] [--strict] [--json] [--prophecy | --ship] FLOW [FLOW...] ``` Reads a flow for what will not run — before anything runs it. Two readings, in their order: a @@ -294,6 +294,8 @@ path of your own. | `--static` | Only the reading that executes nothing: do not load the flow at all. | | `--strict` | Exit non-zero on warnings too. | | `--json` | One JSON object per finding, one a line, for a script to read. | +| `--prophecy` | Print what each [atlas](/guide/atlas) compiles to instead of what is wrong with it. | +| `--ship` | Write each atlas's prophecy into its own directory, for runs of it to walk. | Each finding prints as `file:line: severity: code: what is wrong`, with a count under them. An error is a flow that cannot run, cannot be answered or cannot end — a loop nothing inside @@ -305,9 +307,17 @@ It exits `0` for flows with nothing blocking (warnings print and pass), `1` wher was found — or any warning, under `--strict` — and `2` for a line to correct or a name no flow answers to. +An [atlas](/guide/atlas) gets the stricter reading of the two automatically, its body being a +declaration rather than a program. `--prophecy` prints the graph that reading compiled, one +line of canonical JSON; `--ship` writes it to `/prophecy.pkl`, which every run of that +flow walks from then on. The two cannot be given together, and a name that is not an atlas +that compiles exits non-zero. + ```sh hmz check official/rlar # one warning: a loop only its reviewer ends hmz check --strict local/mine # hold a flow of your own to the whole bar +hmz check --prophecy local/mine # the graph it compiles to, for a diff to read +hmz check --ship local/mine # and beside the flow, for runs of it to walk ``` ## `hmz flowverses` diff --git a/docs/reference/flows.md b/docs/reference/flows.md index 9dc1e9f..13e1ec5 100644 --- a/docs/reference/flows.md +++ b/docs/reference/flows.md @@ -1213,6 +1213,113 @@ briefed() # the same, rendered as one page to steer by Read off the live interface at call time — the moments off the enum, the backend sets off the driver classes' own declarations — so what it promises is what this installation serves. +## An atlas + +An atlas is a flow whose body is read rather than run: a narrower Python, compiled before +anything happens into a **prophecy** — the graph of what the run will do. The guide is +[An atlas](/guide/atlas); this is the surface. + +```python +from hmz.flows import Agent, atlas, canonical, digest, logic, mind, prophesied, sub +``` + +| Mark | What it makes | +| --- | --- | +| `@atlas` | A flow whose body is a declaration. Takes everything `@flow` takes but `resumable`, which is always on. | +| `@mind` | A node that is one turn, handed the agent its call site names. Exactly one way out. | +| `@logic` | A node that is Python and drives nothing. May have several ways out. | +| `sub("official/x")` | The atlas one supernode is, by the name `-f` takes. | + +`@mind` and `@logic` take `rerun=False` for a node a run picked up inside steps past rather +than runs again; such a node answers with nothing. + +An atlas's entry point takes its agents as a `NamedTuple` of them, then the one thing it is +called with — `str` for one a command line runs, a model for one that is only ever a +supernode — and then, for one that says it can be set up, a config: + +```python +@atlas +def run(agents: Agents, task: str, config: Config | None = None) -> None: ... +``` + +### The body + +One node per statement; the branches between them are the edges. Nothing else: + +| | | +| --- | --- | +| `x = call(a, b)` | one node, bound to `x` | +| `call(a, b)` | one node whose answer nothing takes | +| `if x:` / `if not x.field:` | a node's several ways out | +| `while x:` / `while not x.field:` | that, with an edge back to the node the test reads | +| `return` / `return x` | the end of the run | +| `pass`, the docstring | nothing | + +Arguments are names the body bound, one field read off one of them, or `agents.`, the +name the entry point gave what it is called with, or the name it gave its config. + +### The prophecy + +```python +from hmz.flows import canonical, digest, prophesied + +held = prophesied(".humanize/flows/mine") +if held.prophecy is not None: + print(canonical(held.prophecy), digest(held.prophecy)) +``` + +`prophesied` answers with `(findings, prophecy)`, the prophecy being None where anything was +an error. A `Prophecy` holds its `name`, what it `takes` and `gives`, the `agents` it drives, +its `nodes`, its `edges`, the `shapes` that flow along them, and one `Prophecy` per supernode +under it. A `Node` carries `at` (its id — the callee, and `:2`, `:3` for the second and third +call to it), `kind`, `calls`, `takes`, `binds`, `gives`, `rerun` and `under`. An `Edge` +carries `out_of`, `into` and a `When` or None; `""` is the way in at one end and the way out +at the other. + +`canonical` is one line of JSON with everything ordered by what it is rather than where it was +written, so two readings of one atlas are the same bytes; `digest` is what a run picked up +again checks itself against. + +### What an atlas is refused for + +Every one of these is an error, and every one is decidable — which is the bargain the narrower +Python makes. The warnings in the table above still come back over the node bodies, and still +do not block. + +| Code | What it found | +| --- | --- | +| `not-an-atlas` | Nothing marked `@atlas`, or a `sub()` naming a flow that is not one. | +| `unstatic-body` | A statement the body may not hold — work, a call inside a call, `elif`, `try`, `async`, a graph with no nodes. | +| `unshaped-node` | A node parameter or answer annotated with something that is no shape. | +| `shape-mismatch` | What flows along an edge is not what the far end takes, or a name bound twice at two shapes. | +| `unbound-read` | A body reads a name nothing has bound. | +| `branching-mind` | A branch hung off a turn, which has one way out. | +| `dead-loop` | A loop whose body changes nothing its head reads. | +| `unnamed-agents` | Agents declared as a plain tuple, so a turn cannot name the one it drives. | +| `unknown-agent` | `agents.x` the flow does not drive, or a supernode driving one it has not got. | +| `unagented-node` | A logic handed an agent, or a mind handed none. | +| `skipped-answer` | `rerun=False` on a node that answers with something. | +| `circular-atlas` | A supernode reaching back into a graph already being compiled. | +| `dynamic-call` | An atlas importing `load`, which answers with a flow that may be anything. | +| `stale-prophecy` | A shipped `prophecy.pkl` that is not what the source now compiles to. | + +### Shipping one + +A flow's directory may hold `prophecy.pkl` beside its entry point, and where it does that is +what runs rather than the atlas compiled again: + +```python +from hmz.sdk import Hmz + +Hmz().flows.foretell("official/review") # writes the prophecy beside the flow +Hmz().flows.prophecy("official/review") # reads what the source compiles to +``` + +or [`hmz check --ship`](/reference/cli#hmz-check) from a command line. The flow's own Python +still has to be there: a prophecy names the functions its nodes are. Reading a shipped +prophecy runs what its bytes say, which is the trust a [flowverse](/guide/security) already +has. + ## Testing a flow A flow is a function, so drive it with something that is not a coding agent: diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index 4132544..6f13d2b 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -87,6 +87,8 @@ run.wait(timeout=60) | `about(named)` | The line a flow says about itself. | | `places(named)` | Every agent it needs chosen for it, in the order it takes them. | | `check(named, static=False)` | Reads it for what will not run, before anything runs it: one finding per thing found, errors and warnings. `static=True` keeps to the reading that executes nothing. | +| `prophecy(named)` | What an [atlas](/guide/atlas) compiles to, or `None` for a flow that is not one or does not compile — which `check` says the reasons for. | +| `foretell(named)` | Writes that prophecy into the flow's own directory, which every run of it walks from then on. | | `configures(named)` | What it can be [set up with](/reference/flows#settings-of-the-flow-s-own), or `None`. | | `resumes(named)` | Whether it says it can be [picked up](/guide/resuming). | | `fork(named, into=None)` | Copies it into this project's own flows, whole. | diff --git a/src/hmz/SPEC.md b/src/hmz/SPEC.md index 40db4db..592dcfc 100644 --- a/src/hmz/SPEC.md +++ b/src/hmz/SPEC.md @@ -673,7 +673,7 @@ that can happen to a flowverse -- added, fetched again, taken away. ## `hmz check` ```shell -hmz check [--static] [--strict] [--json] [...] +hmz check [--static] [--strict] [--json] [--prophecy | --ship] [...] ``` Reads a flow for what will not run, before anything runs it. @@ -691,6 +691,11 @@ Reads a flow for what will not run, before anything runs it. - It MUST exit 0 for flows with nothing blocking -- warnings print and pass -- 1 where any error was found, or any warning under `--strict`, and 2 for a line to correct or a name no flow answers to, refused as argparse refuses one. +- What an atlas compiles to MUST be sayable from here, since the line that checks a flow is + the line that has just read it: `--prophecy` MUST print the canonical prophecy in place of + the findings, and `--ship` MUST write it into the flow's own directory for every run of it + from then on to walk. The two MUST NOT be given together, and a name that is not an atlas + that compiles MUST be said and MUST exit non-zero. ## `hmz agents` diff --git a/src/hmz/flows/SPEC.md b/src/hmz/flows/SPEC.md index f4c2473..e6b9f8e 100644 --- a/src/hmz/flows/SPEC.md +++ b/src/hmz/flows/SPEC.md @@ -6,11 +6,14 @@ . ├── __init__.py ├── agent.py +├── atlas.py ├── builtin ├── checking.py ├── driving.py +├── prophesying.py ├── proving.py ├── skills.py +├── stepping.py └── verses.py ``` @@ -107,6 +110,9 @@ def inside(named_: str) -> str: ... def about(named_: str) -> str: ... +def foretold(named_: str) -> str: ... + + def __getattr__(name: str) -> object: ... ``` @@ -137,6 +143,13 @@ def __getattr__(name: str) -> object: ... a path with the extension left off is how a single-file flow is written down everywhere a name is not, and one shape resolving where the other does not is a flow that is offered and cannot be run. +- A flow's directory MAY hold the prophecy its atlas was already compiled to, beside the + entry point. Where there is one it MUST be what runs: the compiling is where an atlas is + refused, and a repository that has been through it has an answer worth carrying rather than + working out again at every run. What is beside it MUST still be there -- a prophecy names + the functions its nodes are, and those are in the flow's own Python -- so a directory + holding a prophecy and no entry point MUST NOT be a flow, the same way one holding neither + is not. - A flow MUST be found by name: the ones humanize ships by a bare name, and every other by the place it came from -- `official/rlar`, `local/scheduler`. The flows of your own MUST be a place like any other, so that one rule says what a flow is called and one list says where @@ -544,6 +557,266 @@ at once and costs what the scenario says. and the config rules MUST be run again on the model the loading actually resolved: a model built out of the static reading's sight is still the one whoever sets the flow up meets. +## `atlas.py` + +```python +AGENTS: str +CONFIG: str +INPUT: str + +type Kind = Literal["mind", "logic", "atlas"] + + +@dataclass(frozen=True, slots=True) +class Atlas: + name: str = "" + + +@dataclass(frozen=True, slots=True) +class Marked: + kind: Kind + rerun: bool = True + + +@dataclass(frozen=True, slots=True) +class Sub: + named: str + + +class Field(NamedTuple): + name: str + shape: str + required: bool + + +class Shape(NamedTuple): + name: str + fields: tuple[Field, ...] = () + + +class Reads(NamedTuple): + reads: str + field: str = "" + + +class When(NamedTuple): + reads: str + field: str + truth: bool + + +class Node(NamedTuple): + at: str + kind: Kind + calls: str + takes: tuple[Reads, ...] = () + binds: str = "" + gives: str = "" + rerun: bool = True + under: str = "" + + +class Edge(NamedTuple): + out_of: str + into: str + when: When | None = None + + +class Prophecy(NamedTuple): + name: str + takes: str + gives: str + agents: tuple[str, ...] + nodes: tuple[Node, ...] + edges: tuple[Edge, ...] + shapes: tuple[Shape, ...] + prophecies: tuple[Prophecy, ...] = () + + def node(self, at: str) -> Node | None: ... + def out_of(self, at: str) -> tuple[Edge, ...]: ... + def under(self, named: str) -> Prophecy | None: ... + + +def atlas[**P, T]( + call: Callable[P, T] | None = None, + /, + *, + name: str = "", + about: str = "", + skills: Iterable[str] = (), + selectable: bool = True, +) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]: ... + + +def mind[**P, T]( + call: Callable[P, T] | None = None, /, *, rerun: bool = True +) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]: ... + + +def logic[**P, T]( + call: Callable[P, T] | None = None, /, *, rerun: bool = True +) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]: ... + + +def sub(named: str) -> Sub: ... + + +def canonical(prophecy: Prophecy) -> str: ... + + +def digest(prophecy: Prophecy) -> str: ... + + +def kept(prophecy: Prophecy) -> bytes: ... + + +def told(said: bytes) -> Prophecy | None: ... +``` + +What an atlas is written in, and the prophecy it compiles to. A flow is a Python file that may +branch any way it likes, and the one thing nothing can ask it is what it is about to do; an +atlas is the other bargain, and this is the vocabulary of both halves. + +- An atlas MUST be a flow. It MUST carry everything `flow` marks a flow with as well as its + own mark, so that everything which already finds, lists, names, refuses and runs a flow goes + on doing so, and only what compiles one has to know there are two kinds. `atlas` MUST mark + rather than wrap, for the reason `flow` MUST. +- An atlas MUST always be able to be picked up where the last run of it left off, and MUST say + so without being asked. A prophecy is a list of nodes with an answer apiece, so what a run of + one has done is something the run itself writes down -- and an atlas therefore MUST NOT be + handed a dict, and MUST NOT declare one: what an ordinary flow keeps by hand is what this + keeps by being a graph. +- There MUST be two kinds of ordinary node and one that is a whole atlas. A `mind` MUST be a + turn taken by an agent and MUST be handed the agent the call site names; a `logic` MUST be + Python and MUST be handed no agent at all; an atlas reached by another atlas MUST be a + supernode, which is one node from outside and one prophecy from within. +- A mind MUST have exactly one way out and a logic MAY have several. A branch is a decision, + and a decision nothing but a model made is a decision no reading of the flow can state, so + what a turn said MUST reach a branch by being read by a logic node. +- A node MAY say that a run picked up inside it steps past it rather than running it again. + What a node says by saying nothing MUST be that it runs again: work cut off partway is work + that was not done. One that says otherwise MUST answer with nothing, since a run stepping + past it has no answer of its for what comes next to be missing. +- An atlas MUST reach another atlas by `sub` and MUST reach an ordinary flow by nothing at + all. `load` answers with a flow that may be anything, and a prophecy with one of those in it + would be a graph with a hole where a node should be -- which is the one thing a prophecy is + for not having. What `sub` answers with MUST never be called: the body it is written in is + read rather than run, and a call MUST say so rather than do something surprising. +- A prophecy MUST be canonical: two readings of the same atlas MUST answer with the same + bytes, and everything in one MUST be ordered by what it is rather than by where it was + written. A body reformatted, a comment added, or two nodes swapped where nothing depends on + the order MUST compile to the same text -- which is what makes `digest` worth writing down. +- A node MUST be a call site rather than a function: a body that calls one thing twice is a + graph with two nodes in it, each with its own answer and its own place in the run. What a + node is called MUST be read off the body's shape rather than off a line number, so that a + file reformatted compiles to the prophecy it already was. +- A node's arguments MUST be read by name rather than off the node that answered them: a body + may bind a name twice, which is what a loop is, and the second binding is what the next round + reads. The run's agents, what the atlas was called with and what it was set up with MUST be + named where a bound name would be, and MUST be spelled so that nothing a body may write + collides with them. +- A prophecy MUST be writable and readable as bytes, for a flowverse that ships one. Reading + those bytes runs what they say, which is the trust a flowverse already has; what MUST be + added is the check that what came back is a prophecy at all, so that a file which is merely + corrupt is refused rather than walked. + +## `prophesying.py` + +```python +class Prophesied(NamedTuple): + findings: tuple[Finding, ...] + prophecy: Prophecy | None + + +def prophesied( + flow: str | os.PathLike[str], + *, + name: str = "", + whole: _Whole | None = None, + through: tuple[tuple[str, str], ...] = (), +) -> Prophesied: ... +``` + +Compiling an atlas: the reading that holds a body to the narrower Python it is written in, and +turns what it declared into the prophecy a run walks. + +- It MUST NOT import or execute anything of the atlas it reads, for the reason `checking.py` + MUST NOT: the atlas most worth compiling is one nobody has read yet, and a compiler that ran + what it was compiling would be the attack it exists to catch. +- Every rule here MUST be an error, and every one of them MUST be decidable. That is the + bargain an atlas makes: `checking.py` proves absences one function at a time and warns where + it cannot be sure, and an atlas is written in the subset where there is nothing to be unsure + about. That reading's warnings MUST still come back over the node bodies and MUST NOT block: + a node body is ordinary Python and MUST be read as it. +- The two readings MUST share one parsing and one set of rules. An atlas is a flow, and the + whole of what makes it one is read next door; a second copy of any of it here would be the + drift both readings exist to catch. +- A body MUST hold only: one call per statement, bound to at most one name; an `if` and a + `while` whose test reads a bound name or one field of it; a `return`; `pass`; and the + docstring. Everything else -- arithmetic, a call inside a call, a comprehension, `try`, + `with`, `import`, `async` -- MUST be refused, each of them being a thing a node does and a + node being where it goes. +- What flows along every edge MUST be checked before anything runs. A node's parameters and + its answer MUST each name a shape, which MUST be a pydantic model the flow's own files + declare or one of the plain kinds; and what arrives MUST be that shape, or a model holding + every field that shape requires at the same shape apiece. A name MUST keep the shape it was + first bound with, so that an edge which fits on the first round of a loop fits on every one. +- An atlas MUST declare its agents as a NamedTuple of them and MUST NOT declare a plain tuple: + every turn in a prophecy names the agent it drives, and a place with no name is a turn + nothing can be pointed at. A mind MUST be handed one of them and a supernode all of them, + and neither MUST be handed anything else. +- A branch MUST follow a node, and MUST NOT follow another branch: an `elif`, or an arm with + nothing in it, is two decisions carried on one edge. A loop MUST leave exactly one node, + which is its head -- what the test reads, answered again each round -- and the body MUST + bind at least one name that head reads, else nothing in the loop can change what it says and + the loop never ends. +- A supernode MUST be compiled into the prophecy reaching for it, and one that reaches back + into an atlas already being compiled MUST be refused. Which atlas a name means MUST be + settled by where it is declared and what it is called there rather than by how it was + spelled, since one atlas is `deeper` beside it and `flow:deeper` from anywhere else -- and a + check that compared spellings would follow that forever. +- Where the flow's own directory ships a prophecy, whether it is still the one this source + compiles to MUST be said. A run walks the shipped one, so a shipped prophecy that has + drifted is a flow that does one thing and reads as another. + +## `stepping.py` + +```python +def walking( + flow: str | os.PathLike[str], inside: Mapping[str, Any], entry: Entry +) -> Entry: ... +``` + +Running a prophecy: one node at a time, and picking a stopped run up where it left off. + +- An atlas's body MUST NOT be run. It is a declaration, and what runs MUST be the prophecy + compiling it made -- which is what puts a run in a position to be stopped and started at + all. +- The compiling MUST happen where a flow is loaded rather than where it is run, and a body + that does not compile MUST be refused there, saying every reason at once. Every way of + running a flow MUST get both the compiling and the walking without knowing there are two + kinds of flow. +- What a run has done MUST be the answers it has, written down as each arrives rather than + when the run ends: a run worth picking up is one that was stopped or killed rather than one + that ended tidily. Each MUST be written down against the node and the visit, since a loop is + one node visited again and a round that overwrote the last round's answer would be a run + nothing could be picked up inside a loop. +- Picking a run up MUST be walking the same prophecy over the same answers until it reaches + the visit that has none, and what happens there MUST be what that node says: run again by + default, stepped past where the node says so. +- A run MUST be picked up into the same prophecy or not at all. What was written down MUST be + written down against the digest, and a run whose prophecy has moved MUST start from the top: + an atlas rewritten between two runs is a different graph whose nodes happen to share their + names, and carrying on into it would be a run resuming into somewhere it has never been. +- A supernode MUST be walked as the prophecy it is, in the run around it, and its own nodes + MUST be written down beneath the visit it is: two graphs, one run, and each node with a line + of its own. One reached by name MUST be read at the call rather than held, for the reason a + called flow is read at the call. +- Where the flow's own directory ships a prophecy for the atlas being run, that prophecy MUST + be what runs rather than one compiled again. One that cannot be read back MUST be refused + rather than compiled again: what a flowverse shipped is what it meant to be run, and + quietly running something else is the one thing shipping it was meant to rule out. + ## `verses.py` ```python diff --git a/src/hmz/flows/prophesying.py b/src/hmz/flows/prophesying.py index 027d937..8a534d4 100644 --- a/src/hmz/flows/prophesying.py +++ b/src/hmz/flows/prophesying.py @@ -197,9 +197,13 @@ def _shipped(whole: _Whole, prophecy: Prophecy) -> list[Finding]: A `stale-prophecy` error where the two differ, and nothing where they agree or where the flow ships none. """ - from . import PROPHECY + from . import ENTRY, PROPHECY from .atlas import told + # Beside the entry point, which means the flow's own directory: a flow that is a single + # file has none, and what is beside such a flow is the other flows. + if whole.entry.name != ENTRY: + return [] at = whole.entry.parent / PROPHECY if not at.is_file(): return [] @@ -1016,12 +1020,7 @@ def _under(self, call: ast.Call, called: str, named: str) -> Prophecy | None: read, mark = beside if self._circular(call, named, _who(read.where, mark.name)): return None - made, found = _compiled( - self.whole._replace(entered=read, compiled=True), - mark, - named, - self.through, - ) + made, found = _compiled(self.whole, mark, named, self.through) self.found.extend(found) return made from . import ENTRY, find, inside diff --git a/src/hmz/sdk/SPEC.md b/src/hmz/sdk/SPEC.md index 3a7dade..99a5199 100644 --- a/src/hmz/sdk/SPEC.md +++ b/src/hmz/sdk/SPEC.md @@ -137,6 +137,8 @@ class Flows: def check( self, named: str | os.PathLike[str], *, static: bool = False ) -> tuple[Finding, ...]: ... + def prophecy(self, named: str | os.PathLike[str]) -> Prophecy | None: ... + def foretell(self, named: str | os.PathLike[str]) -> str: ... def configures(self, named: str | os.PathLike[str]) -> type[BaseModel] | None: ... def resumes(self, named: str | os.PathLike[str]) -> bool: ... def fork(self, named: str, into: str | os.PathLike[str] | None = None) -> str: ... @@ -149,6 +151,10 @@ The flows there are, and the places they come from. - `check` MUST be the two readings of `hmz.flows` in their order -- the static one, then the flow loaded in a subprocess -- and the second MUST NOT run where the first found an error, nor say again what the first already said: one call is one answer, whichever way in asked. +- `prophecy` MUST answer with what an atlas compiles to and with nothing for a flow that is + not one or does not compile, `check` being where the reasons are said. `foretell` MUST write + that prophecy into the flow's own directory, which is what every run of it walks from then + on, and MUST refuse a flow there is none for rather than writing something that is not one. - Where a flowverse came from MUST be answered here, and MUST be answered from which flowverse it is rather than from whether its URL is empty. Whatever was signed into a URL MUST be taken out of it in one place, which every way of showing one asks. From 810aa936fc48b577f05ff55489c7b0d948bab9b1 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:25:54 +0000 Subject: [PATCH 11/18] fix(flows): refuse to ship a prophecy for a flow that is one file Such a flow has no directory of its own -- what is beside it is the other flows -- so writing `prophecy.pkl` there wrote into a path with a `.py` in the middle of it and came back as an OSError. Said as the refusal it is, and the one-file atlas is now covered both ways: it compiles and runs, and it says why it cannot be shipped. Co-Authored-By: Claude Opus 5 (1M context) --- src/hmz/sdk/flows.py | 11 +++++++++-- tests/test_stepping.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/hmz/sdk/flows.py b/src/hmz/sdk/flows.py index 6c4c94e..e392952 100644 --- a/src/hmz/sdk/flows.py +++ b/src/hmz/sdk/flows.py @@ -283,7 +283,9 @@ def foretell(self, named: str | os.PathLike[str]) -> str: Where it was written. Raises: - NotAFlow: If it is not an atlas, or does not compile. + NotAFlow: If it is not an atlas, does not compile, or is a flow that is a single + file -- which has no directory of its own to ship anything in, what is beside + such a flow being the other flows. """ from pathlib import Path @@ -293,7 +295,12 @@ def foretell(self, named: str | os.PathLike[str]) -> str: if held is None: raise NotAFlow(f"{named}: not an atlas that compiles -- hmz check says why") at = Path(find(str(named))) - into = (at.parent if at.name == ENTRY else at) / PROPHECY + if at.name != ENTRY: + raise NotAFlow( + f"{named}: a flow that is one file has no directory to ship a prophecy " + "in -- make it a directory with an __init__.py in it" + ) + into = at.parent / PROPHECY into.write_bytes(kept(held)) return str(into) diff --git a/tests/test_stepping.py b/tests/test_stepping.py index f88d9e0..6546d3b 100644 --- a/tests/test_stepping.py +++ b/tests/test_stepping.py @@ -417,3 +417,20 @@ def test_one_is_shipped_and_read_back_through_the_sdk(project: Path) -> None: assert said is not None assert [one.at for one in said.nodes] == ["write", "judge", "write:2"] assert flows.check("rounds") == () + + +def test_an_atlas_may_be_one_file(project: Path) -> None: + """A flow that is one function still is one, and so is an atlas that is one graph.""" + (project / ".humanize/flows/lone.py").write_text(ROUNDS) + + _run("lone") + + assert (project / "rounds.txt").read_text() == "www" + + +def test_a_flow_that_is_one_file_has_nowhere_to_ship_a_prophecy(project: Path) -> None: + """What is beside such a flow is the other flows, and none of it came with this one.""" + (project / ".humanize/flows/lone.py").write_text(ROUNDS) + + with pytest.raises(NotAFlow, match="no directory to ship a prophecy in"): + Hmz().flows.foretell("lone") From 04a8fee125e66ca867572ace7016f937a6802629 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:28:00 +0000 Subject: [PATCH 12/18] fix(flows): refuse a supernode whose atlas says it can be set up What is set up is the run, and a supernode is a node: one reached as a node was handed no config, so every node inside it that read one read None -- quietly, an hour in. Refused where the supernode is written instead, and the prophecy now carries what the atlas it came from can be set up with, which is what makes the refusal readable across flows. Co-Authored-By: Claude Opus 5 (1M context) --- src/hmz/flows/SPEC.md | 4 +++ src/hmz/flows/atlas.py | 2 ++ src/hmz/flows/prophesying.py | 13 ++++++++ tests/test_prophesying.py | 60 ++++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+) diff --git a/src/hmz/flows/SPEC.md b/src/hmz/flows/SPEC.md index e6b9f8e..e13feca 100644 --- a/src/hmz/flows/SPEC.md +++ b/src/hmz/flows/SPEC.md @@ -626,6 +626,7 @@ class Prophecy(NamedTuple): name: str takes: str gives: str + config: str agents: tuple[str, ...] nodes: tuple[Node, ...] edges: tuple[Edge, ...] @@ -770,6 +771,9 @@ turns what it declared into the prophecy a run walks. which is its head -- what the test reads, answered again each round -- and the body MUST bind at least one name that head reads, else nothing in the loop can change what it says and the loop never ends. +- A supernode MUST be an atlas that takes no config. What is set up is the run, so an atlas + that says it can be set up is one to start rather than one to reach for -- and one reached + as a node would otherwise read a config nothing ever handed it. - A supernode MUST be compiled into the prophecy reaching for it, and one that reaches back into an atlas already being compiled MUST be refused. Which atlas a name means MUST be settled by where it is declared and what it is called there rather than by how it was diff --git a/src/hmz/flows/atlas.py b/src/hmz/flows/atlas.py index 8b37e6d..f34c8d7 100644 --- a/src/hmz/flows/atlas.py +++ b/src/hmz/flows/atlas.py @@ -493,6 +493,7 @@ class Prophecy(NamedTuple): name: str takes: str gives: str + config: str agents: tuple[str, ...] nodes: tuple[Node, ...] edges: tuple[Edge, ...] @@ -570,6 +571,7 @@ def _written(prophecy: Prophecy) -> dict[str, object]: "name": prophecy.name, "takes": prophecy.takes, "gives": prophecy.gives, + "config": prophecy.config, "agents": list(prophecy.agents), "nodes": [ { diff --git a/src/hmz/flows/prophesying.py b/src/hmz/flows/prophesying.py index 8a534d4..f340cb8 100644 --- a/src/hmz/flows/prophesying.py +++ b/src/hmz/flows/prophesying.py @@ -401,6 +401,7 @@ def _compiled( name=named, takes=takes, gives=wiring.gives, + config=config, agents=agents, nodes=tuple(wiring.nodes), edges=tuple(wiring.edges), @@ -983,6 +984,18 @@ def _supernode(self, call: ast.Call, called: str) -> _Declared | None: if under is None: return None self.prophecies.append(under) + if under.config: + self.found.append( + _said( + "unstatic-body", + self.where, + call.lineno, + f"{named} says it can be set up, and a supernode is a node: what is " + "set up is the run, so an atlas that takes a config is one to start " + "rather than one to reach for", + ) + ) + return None short = set(under.agents) - set(self.agents) if short: self.found.append( diff --git a/tests/test_prophesying.py b/tests/test_prophesying.py index 723eb7a..c31e3dc 100644 --- a/tests/test_prophesying.py +++ b/tests/test_prophesying.py @@ -451,3 +451,63 @@ def test_a_prophecy_that_cannot_be_read_back_is_not_walked(tmp_path: Path) -> No (at / PROPHECY).write_bytes(b"nothing here is a prophecy") assert "stale-prophecy" in {one.code for one in checked(at)} + + +def test_a_supernode_that_says_it_can_be_set_up_is_refused(tmp_path: Path) -> None: + """What is set up is the run, so such an atlas is one to start and not one to reach.""" + body = '''class Config(BaseModel): + """What it takes.""" + + model_config = {"extra": "forbid"} + + rounds: int = Field(default=2, description="how many rounds it takes") + + +@atlas(name="inner") +def inner(agents: Agents, said: Draft, config: Config | None = None) -> Verdict: + """A graph that says it can be set up.""" + verdict = judge(said) + return verdict + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + verdict = inner(agents, draft) +''' + assert "unstatic-body" in _codes(tmp_path, body) + + +def test_what_an_atlas_can_be_set_up_with_is_part_of_the_prophecy( + tmp_path: Path, +) -> None: + """A node may read the config, so what it is is what the graph is checked against.""" + body = '''class Config(BaseModel): + """What it takes.""" + + model_config = {"extra": "forbid"} + + rounds: int = Field(default=2, description="how many rounds it takes") + + +@logic +def bounded(said: Draft, rounds: int) -> Verdict: + """Reads the draft against the bound the run was set up with.""" + return Verdict(done=bool(said.text) and rounds > 0) + + +@atlas +def run(agents: Agents, task: str, config: Config | None = None) -> None: + """Says it.""" + draft = write(agents.writer, task) + verdict = bounded(draft, config.rounds) +''' + prophecy = _held(tmp_path, body).prophecy + assert prophecy is not None + + assert prophecy.config == "Config" + node = prophecy.node("bounded") + assert node is not None + assert node.takes[1].reads == "@config" + assert node.takes[1].field == "rounds" From a542cee64f307bcd9871e825fea904fe77b0235a Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:31:34 +0000 Subject: [PATCH 13/18] fix(flows): answer with what the return named, not with the last node's answer A body may end `return draft` three nodes after `draft` was bound, and the walk answered with whatever ran last -- so a supernode handed its caller the wrong value, silently. The way out of a prophecy now carries the name the run answers with, an atlas that says it answers with something is held to saying so on every way out, and a return is a whole name rather than a field of one, a field being a thing a logic node reads. Co-Authored-By: Claude Opus 5 (1M context) --- src/hmz/flows/atlas.py | 8 ++++++- src/hmz/flows/prophesying.py | 29 ++++++++++++++++++++----- src/hmz/flows/stepping.py | 12 ++++++----- tests/test_prophesying.py | 42 ++++++++++++++++++++++++++++++++++++ tests/test_stepping.py | 19 ++++++++++++++++ 5 files changed, 99 insertions(+), 11 deletions(-) diff --git a/src/hmz/flows/atlas.py b/src/hmz/flows/atlas.py index f34c8d7..43605da 100644 --- a/src/hmz/flows/atlas.py +++ b/src/hmz/flows/atlas.py @@ -467,11 +467,15 @@ class Edge(NamedTuple): into: The node it arrives at, and "" for the way out of it, which is where the run ends. when: What has to hold for this to be the way taken, and None for a node's only one. + answers: For a way out of the prophecy, the name the run answers with -- which is what + the `return` named, and not whatever the last node happened to say. "" everywhere + else, and for an atlas that answers with nothing. """ out_of: str into: str when: When | None = None + answers: str = "" class Prophecy(NamedTuple): @@ -593,6 +597,7 @@ def _written(prophecy: Prophecy) -> dict[str, object]: "when": None if one.when is None else [one.when.reads, one.when.field, one.when.truth], + "answers": one.answers, } for one in sorted(prophecy.edges, key=_ordered) ], @@ -612,7 +617,7 @@ def _written(prophecy: Prophecy) -> dict[str, object]: } -def _ordered(edge: Edge) -> tuple[str, str, str, str, bool]: +def _ordered(edge: Edge) -> tuple[str, str, str, str, bool, str]: """One edge as something two of them can be sorted by, an absent guard and all.""" when = edge.when return ( @@ -621,6 +626,7 @@ def _ordered(edge: Edge) -> tuple[str, str, str, str, bool]: "" if when is None else when.reads, "" if when is None else when.field, when is not None and when.truth, + edge.answers, ) diff --git a/src/hmz/flows/prophesying.py b/src/hmz/flows/prophesying.py index f340cb8..32eeb79 100644 --- a/src/hmz/flows/prophesying.py +++ b/src/hmz/flows/prophesying.py @@ -577,6 +577,20 @@ def walk(self, body: list[ast.stmt]) -> None: body: The entry point's statements. """ for out_of, when in self._block(body, [("", None)]): + # A path that runs off the bottom of the body ends the run, and answers with + # nothing: an atlas that says it answers with something says so on every way + # out of it, which is what a `return` there is. + if self.gives: + self.found.append( + _said( + "shape-mismatch", + self.where, + body[-1].lineno if body else 0, + f"the atlas answers with {self.gives}, and this way out of it ends " + "without returning anything", + ) + ) + break self.edges.append(Edge(out_of, "", when)) if not self.nodes: self.found.append( @@ -630,12 +644,17 @@ def _return(self, node: ast.Return, loose: _Loose) -> None: loose: The ends arriving at it. """ given = NOTHING + answers = "" if node.value is not None: - read = self._reads(node.value) - if read is None: - self._refuse(node, "an atlas returns a name a node bound, or nothing") + if not isinstance(node.value, ast.Name): + self._refuse( + node, + "an atlas returns a name a node bound, whole -- a field of one is a " + "thing a logic node reads", + ) return - given = self._shape_of(read) or NOTHING + answers = node.value.id + given = self._shape_of((answers, "")) or NOTHING if given != (self.gives or NOTHING): self.found.append( _said( @@ -647,7 +666,7 @@ def _return(self, node: ast.Return, loose: _Loose) -> None: ) ) for out_of, when in loose: - self.edges.append(Edge(out_of, "", when)) + self.edges.append(Edge(out_of, "", when, answers)) def _branch(self, node: ast.If, loose: _Loose) -> _Loose: """An `if`, which is the several ways out of the node above it. diff --git a/src/hmz/flows/stepping.py b/src/hmz/flows/stepping.py index e0e0eb4..f312aff 100644 --- a/src/hmz/flows/stepping.py +++ b/src/hmz/flows/stepping.py @@ -216,13 +216,14 @@ def _walked( """ bound: dict[str, Any] = {AGENTS: agents, INPUT: given, CONFIG: config} seen: dict[str, int] = {} - answered: Any = None at = "" while True: edge = _way(prophecy, at, bound) node = None if edge is None else prophecy.node(edge.into) if node is None: - return answered + # What the `return` named, and not whatever the last node happened to say: a + # body may answer with something it bound three nodes ago. + return bound.get(edge.answers) if edge is not None else None seen[node.at] = visit = seen.get(node.at, 0) + 1 held = f"{under}{node.at}{_VISIT}{visit}" answered = _answered( @@ -242,8 +243,9 @@ def _way(prophecy: Prophecy, at: str, bound: dict[str, Any]) -> Edge | None: bound: What each name holds now. Returns: - The edge, or None where this is the end of the run -- either because nothing leads on - from here, or because the way out that does leads out of the prophecy. + The edge. One whose far end is "" is the way out of the prophecy, and what the run + answers with is on it; None is a node nothing leads on from, which nothing that + compiled can be. """ for edge in prophecy.out_of(at): when = edge.when @@ -251,7 +253,7 @@ def _way(prophecy: Prophecy, at: str, bound: dict[str, Any]) -> Edge | None: when is None or bool(_read(bound, Reads(when.reads, when.field))) is when.truth ): - return edge if edge.into else None + return edge return None diff --git a/tests/test_prophesying.py b/tests/test_prophesying.py index c31e3dc..7b52784 100644 --- a/tests/test_prophesying.py +++ b/tests/test_prophesying.py @@ -511,3 +511,45 @@ def run(agents: Agents, task: str, config: Config | None = None) -> None: assert node is not None assert node.takes[1].reads == "@config" assert node.takes[1].field == "rounds" + + +def test_a_way_out_carries_what_the_atlas_answers_with(tmp_path: Path) -> None: + """Which is what the `return` named, and not whatever the last node happened to say.""" + body = '''@atlas(name="inner") +def inner(agents: Agents, said: Draft) -> Draft: + """Answers with something it bound two nodes ago.""" + draft = write(agents.writer, said.text) + verdict = judge(draft) + return draft + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + again = inner(agents, draft) +''' + prophecy = _held(tmp_path, body).prophecy + assert prophecy is not None + under = prophecy.under("inner") + assert under is not None + + out = next(one for one in under.edges if not one.into) + assert out.answers == "draft" + + +def test_an_atlas_that_answers_says_so_on_every_way_out(tmp_path: Path) -> None: + """A path running off the bottom of the body answers with nothing, which is not it.""" + body = '''@atlas(name="inner") +def inner(agents: Agents, said: Draft) -> Draft: + """Says it answers with a draft and runs off the bottom instead.""" + draft = write(agents.writer, said.text) + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + again = inner(agents, draft) +''' + assert "shape-mismatch" in _codes(tmp_path, body) diff --git a/tests/test_stepping.py b/tests/test_stepping.py index 6546d3b..f501fb3 100644 --- a/tests/test_stepping.py +++ b/tests/test_stepping.py @@ -434,3 +434,22 @@ def test_a_flow_that_is_one_file_has_nowhere_to_ship_a_prophecy(project: Path) - with pytest.raises(NotAFlow, match="no directory to ship a prophecy in"): Hmz().flows.foretell("lone") + + +def test_a_supernode_answers_with_what_its_return_named(project: Path) -> None: + """A graph may answer with something it bound three nodes before it ended.""" + inner = INNER.replace( + """ out = deepen(agents.writer, said) + return out""", + """ out = deepen(agents.writer, said) + more = deepen(agents.writer, out) + return out""", + ) + _write(project, "inner", inner) + _write(project, "outer", OUTER) + + _run("outer", "hello") + + # Two turns were taken, and what came back out is the first one's answer. + assert (project / "ran.txt").read_text() == "start deepen deepen " + assert (project / "out.txt").read_text() == "hello!" From a45b0525d3ffa7b5c770f11e8da4e610bc41d9d3 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 29 Aug 2026 07:51:25 +0000 Subject: [PATCH 14/18] refactor(flows): what four cleanup readings of the atlas found Two of them were bugs rather than cleanups, and both were silent. `hmz check official/review:pass` reported `not-an-atlas`: the dispatch to the compiling sat behind a seam that never took a name, so which of a file's atlases was asked for was dropped two lines before it was needed. The choice moves to where both halves of the name are held, and `is_atlas` is the one predicate it turns on. And the bodies an atlas compiles were left out of the ordinary reading by name, so a class beside an atlas with a method of the same name lost every rule -- loop bounds, agent surface, hook moments -- with nothing said. They are left out by identity now. The rest is what the readings were asked for. Reading a flow no longer compiles it: `resumes`, `configures` and `wanted` are answered off the entry point's own annotation, and an atlas that does not compile no longer makes a flow picker say it cannot be picked up -- measured, six compiles for those three questions became none. A node's state was written twice where once would do. A supernode of another flow was read again at every visit, which is new code under a graph already settled. The walk holds what every step of it is against instead of handing nine arguments down four functions, and holds the prophecy's nodes and edges by what a step looks them up by. Reuse, where the reading beside this one already had it: `_annotated` for what names an agent -- which also stops `list[Agent]` reading as one -- `_unquoted` for a quoted annotation, the file's own alias for pydantic's `Field`, and one `shipped()` for the three places that opened `prophecy.pkl` and disagreed about a single-file flow. `canonical` reads the tuples themselves, so a field added to a node later is one the digest sees rather than one it silently drops. Co-Authored-By: Claude Opus 5 (1M context) --- src/hmz/cli/check.py | 10 +- src/hmz/flows/SPEC.md | 16 +- src/hmz/flows/__init__.py | 38 ++++- src/hmz/flows/atlas.py | 140 ++++++++++-------- src/hmz/flows/checking.py | 77 +++++++--- src/hmz/flows/driving.py | 62 +++++++- src/hmz/flows/prophesying.py | 251 +++++++++++++++++++------------ src/hmz/flows/stepping.py | 279 ++++++++++++++++++----------------- src/hmz/sdk/flows.py | 38 ++--- tests/test_prophesying.py | 37 +++++ 10 files changed, 590 insertions(+), 358 deletions(-) diff --git a/src/hmz/cli/check.py b/src/hmz/cli/check.py index 15d5b25..62ff6e0 100644 --- a/src/hmz/cli/check.py +++ b/src/hmz/cli/check.py @@ -18,6 +18,7 @@ if TYPE_CHECKING: from hmz.flows import Finding + from hmz.sdk.flows import Flows __all__ = ["check"] @@ -89,7 +90,7 @@ def check(argv: list[str]) -> int: if not Path(flows.find(named)).is_file(): parser.error(f"no flow called {named!r}") if args.prophecy or args.ship: - return _foretold(args.flow, ship=args.ship) + return _foretold(flows, args.flow, ship=args.ship) found: list[Finding] = [] for named in args.flow: found.extend(flows.check(named, static=args.static)) @@ -99,11 +100,12 @@ def check(argv: list[str]) -> int: return 1 if errors or (args.strict and warned) else 0 -def _foretold(flows: list[str], *, ship: bool) -> int: +def _foretold(held: Flows, flows: list[str], *, ship: bool) -> int: """Prints or writes what each atlas on the line compiles to. Args: - flows: The flows, by the names the line gave. + held: The flows, as the one object every way in asks. + flows: The flows to read, by the names the line gave. ship: Whether to write each prophecy into its flow's own directory rather than print it. @@ -112,9 +114,7 @@ def _foretold(flows: list[str], *, ship: bool) -> int: an atlas, or is one that the reading refused. """ from hmz.flows import NotAFlow, canonical - from hmz.sdk import Hmz - held = Hmz().flows worst = 0 for named in flows: if ship: diff --git a/src/hmz/flows/SPEC.md b/src/hmz/flows/SPEC.md index e13feca..9b5e114 100644 --- a/src/hmz/flows/SPEC.md +++ b/src/hmz/flows/SPEC.md @@ -796,10 +796,13 @@ Running a prophecy: one node at a time, and picking a stopped run up where it le - An atlas's body MUST NOT be run. It is a declaration, and what runs MUST be the prophecy compiling it made -- which is what puts a run in a position to be stopped and started at all. -- The compiling MUST happen where a flow is loaded rather than where it is run, and a body - that does not compile MUST be refused there, saying every reason at once. Every way of - running a flow MUST get both the compiling and the walking without knowing there are two - kinds of flow. +- The compiling MUST happen before the first node runs, and MUST NOT happen where a flow is + only read. What a flow drives, what it can be set up with and whether it can be picked up + are questions its entry point's own annotation answers, and an atlas that had to be + compiled to be asked one of them would be an atlas a flow picker could not list -- and one + that does not compile would answer no rather than say why. A body that does not compile + MUST be refused where it is run, saying every reason at once, and every way of running a + flow MUST get both the compiling and the walking without knowing there are two kinds. - What a run has done MUST be the answers it has, written down as each arrives rather than when the run ends: a run worth picking up is one that was stopped or killed rather than one that ended tidily. Each MUST be written down against the node and the visit, since a loop is @@ -814,8 +817,9 @@ Running a prophecy: one node at a time, and picking a stopped run up where it le names, and carrying on into it would be a run resuming into somewhere it has never been. - A supernode MUST be walked as the prophecy it is, in the run around it, and its own nodes MUST be written down beneath the visit it is: two graphs, one run, and each node with a line - of its own. One reached by name MUST be read at the call rather than held, for the reason a - called flow is read at the call. + of its own. A flow reached by name MUST be read once for the run rather than once a visit: + the shape was settled before anything ran, and a file re-read between two rounds of a loop + would be new code running under a graph already agreed. - Where the flow's own directory ships a prophecy for the atlas being run, that prophecy MUST be what runs rather than one compiled again. One that cannot be read back MUST be refused rather than compiled again: what a flowverse shipped is what it meant to be run, and diff --git a/src/hmz/flows/__init__.py b/src/hmz/flows/__init__.py index 244646b..845ea60 100644 --- a/src/hmz/flows/__init__.py +++ b/src/hmz/flows/__init__.py @@ -57,12 +57,14 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: Node, Prophecy, Shape, + Shipped, atlas, canonical, digest, kept, logic, mind, + shipped, sub, told, ) @@ -80,7 +82,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: running, wanted, ) -from .prophesying import Prophesied, prophesied +from .prophesying import Prophesied, is_atlas, prophesied from .proving import ALWAYS_DONE, NEVER_DONE, SILENT, Outcome, Proof, Scenario, proved from .verses import ( BUILTIN, @@ -186,6 +188,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "Scenario", "Session", "Shape", + "Shipped", "Stopped", "Tool", "Unhooked", @@ -216,6 +219,7 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "holds", "home", "inside", + "is_atlas", "kept", "load", "loaded", @@ -227,8 +231,10 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "offers", "prophesied", "proved", + "reading", "resumes", "running", + "shipped", "sub", "told", "wanted", @@ -796,6 +802,28 @@ def find(named_: str) -> str: return at_ +def reading(named_: str) -> str: + """What to point a reading of one flow at, which is not always what runs it. + + A flow is a directory or a single file, and the two readings of one -- the checking and + the compiling -- take the whole of it either way: the directory where there is one, so + that what the entry point imports beside it is read too, and the file where there is + not. :func:`find` answers with the entry point instead, that being what is run. + + Args: + named_: A flow's name, as :func:`find` takes it. + + Returns: + The path to read: the flow's own directory, or the file a single-file flow is. A name + nothing answers to comes back as :func:`find` left it, so whatever asked hears about + it where it looks rather than here. + """ + found_ = find(named_) + if os.path.isfile(found_) and os.path.basename(found_) == ENTRY: + return os.path.dirname(found_) + return found_ + + def foretold(named_: str) -> str: """Where the prophecy one flow ships is, for a flow that ships one. @@ -815,11 +843,11 @@ def foretold(named_: str) -> str: The path to it, and "" for a flow that ships none -- which is every flow that is not an atlas, and most atlases. """ + from .atlas import shipped + beside = at(named_) - if not beside: - return "" - held = os.path.join(beside, PROPHECY) - return held if os.path.isfile(held) else "" + held = shipped(beside) if beside else None + return "" if held is None else str(held.at) def at(named_: str) -> str: diff --git a/src/hmz/flows/atlas.py b/src/hmz/flows/atlas.py index 43605da..491eda1 100644 --- a/src/hmz/flows/atlas.py +++ b/src/hmz/flows/atlas.py @@ -56,9 +56,11 @@ def run(agents: Agents, task: str) -> None: from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Literal, NamedTuple, overload +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, overload if TYPE_CHECKING: + import os from collections.abc import Callable, Iterable __all__ = [ @@ -73,6 +75,7 @@ def run(agents: Agents, task: str) -> None: "Prophecy", "Reads", "Shape", + "Shipped", "Sub", "When", "atlas", @@ -81,6 +84,7 @@ def run(agents: Agents, task: str) -> None: "kept", "logic", "mind", + "shipped", "sub", "told", ] @@ -235,12 +239,7 @@ def write(agent: Agent, task: str) -> Draft: Returns: The function, unchanged but for what it now says about itself. """ - - def marks(said: Callable[P, T]) -> Callable[P, T]: - setattr(said, MARKED, Marked("mind", rerun=rerun)) - return said - - return marks if call is None else marks(call) + return _noded("mind", call, rerun=rerun) @overload @@ -276,9 +275,29 @@ def judge(said: Draft) -> Verdict: Returns: The function, unchanged but for what it now says about itself. """ + return _noded("logic", call, rerun=rerun) + + +def _noded[**P, T]( + kind: Kind, call: Callable[P, T] | None, *, rerun: bool +) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]: + """Marks one function as a node, whichever of the two kinds it is. + + What the two marks share is the whole of how a decorator written bare and one written + with arguments are told apart, which is a protocol worth having in one place rather than + two: what differs between them is which kind it is, and what each says for itself. + + Args: + kind: Which kind of node the mark makes it. + call: The function, where the mark was written with no arguments at all. + rerun: Whether a run picked up inside it runs it again. + + Returns: + The function where there was one, and something to mark one where there was not. + """ def marks(said: Callable[P, T]) -> Callable[P, T]: - setattr(said, MARKED, Marked("logic", rerun=rerun)) + setattr(said, MARKED, Marked(kind, rerun=rerun)) return said return marks if call is None else marks(call) @@ -560,56 +579,26 @@ def canonical(prophecy: Prophecy) -> str: return json.dumps(_written(prophecy), sort_keys=True, ensure_ascii=False) -def _written(prophecy: Prophecy) -> dict[str, object]: +def _written(prophecy: Prophecy) -> dict[str, Any]: """One prophecy as the plain objects :func:`canonical` writes out. + Read off the tuples themselves rather than field by field: everything here is a + NamedTuple, so a field added to one later is a field the canonical text carries and the + digest sees -- where a hand-written list of them would drop it without saying so, and + two prophecies that differ would hash the same. + Args: prophecy: The compiled atlas. Returns: - Its nodes by id, its edges in order, its shapes by name and its sub-prophecies by name -- - each sorted, since the order a body happens to be written in is not part of what the - atlas is. + Its nodes by id, its edges in order, its shapes by name and the prophecies under it by + name -- each sorted, since the order a body happens to be written in is not part of + what the atlas is. """ - return { - "name": prophecy.name, - "takes": prophecy.takes, - "gives": prophecy.gives, - "config": prophecy.config, - "agents": list(prophecy.agents), - "nodes": [ - { - "at": one.at, - "kind": one.kind, - "calls": one.calls, - "takes": [[read.reads, read.field] for read in one.takes], - "binds": one.binds, - "gives": one.gives, - "rerun": one.rerun, - "under": one.under, - } - for one in sorted(prophecy.nodes) - ], - "edges": [ - { - "out_of": one.out_of, - "into": one.into, - "when": None - if one.when is None - else [one.when.reads, one.when.field, one.when.truth], - "answers": one.answers, - } - for one in sorted(prophecy.edges, key=_ordered) - ], - "shapes": [ - { - "name": one.name, - "fields": [ - [held.name, held.shape, held.required] for held in one.fields - ], - } - for one in sorted(prophecy.shapes) - ], + return prophecy._asdict() | { + "nodes": [one._asdict() for one in sorted(prophecy.nodes)], + "edges": [one._asdict() for one in sorted(prophecy.edges, key=_ordered)], + "shapes": [one._asdict() for one in sorted(prophecy.shapes)], "prophecies": [ _written(one) for one in sorted(prophecy.prophecies, key=lambda one: one.name) @@ -617,17 +606,9 @@ def _written(prophecy: Prophecy) -> dict[str, object]: } -def _ordered(edge: Edge) -> tuple[str, str, str, str, bool, str]: +def _ordered(edge: Edge) -> tuple[str, str, tuple[str, str, bool], str]: """One edge as something two of them can be sorted by, an absent guard and all.""" - when = edge.when - return ( - edge.out_of, - edge.into, - "" if when is None else when.reads, - "" if when is None else when.field, - when is not None and when.truth, - edge.answers, - ) + return (edge.out_of, edge.into, edge.when or ("", "", False), edge.answers) #: What a shipped prophecy is written with. Fixed rather than highest, so that the same @@ -685,6 +666,43 @@ def told(said: bytes) -> Prophecy | None: return held +class Shipped(NamedTuple): + """What one flow's own directory ships beside its entry point. + + Attributes: + at: The file it is in, which is `prophecy.pkl` beside the flow. + prophecy: What it says, and None for bytes that are not a prophecy at all -- which is + a file to compile again rather than a graph to guess at. Every reader of a shipped + prophecy decides that for itself: one refuses the run, one says so as a finding. + """ + + at: Path + prophecy: Prophecy | None + + +def shipped(under: str | os.PathLike[str]) -> Shipped | None: + """The prophecy one flow's own directory ships, where it ships one. + + The one place `prophecy.pkl` is opened. Where it is, whether it is there, and what it + takes to read it back are one rule rather than one per reader -- and what to do about a + file that will not read back is each reader's own, since a run refuses and a checking + says so. + + Args: + under: The flow's own directory. A flow that is a single file has none, and passing + the file is answered the same way as passing a directory with nothing in it. + + Returns: + Where it is and what it says, or None where the flow ships nothing. + """ + from . import PROPHECY + + at = Path(under) / PROPHECY + if not at.is_file(): + return None + return Shipped(at, told(at.read_bytes())) + + def digest(prophecy: Prophecy) -> str: """What one compiled atlas is, in sixteen characters. diff --git a/src/hmz/flows/checking.py b/src/hmz/flows/checking.py index 99430fb..0fc7ed0 100644 --- a/src/hmz/flows/checking.py +++ b/src/hmz/flows/checking.py @@ -31,7 +31,7 @@ if TYPE_CHECKING: import os - from collections.abc import Iterator, Sequence + from collections.abc import Iterator, Mapping, Sequence __all__ = [ "Capability", @@ -146,6 +146,10 @@ class _Whole(NamedTuple): parse, a directory that holds no flow. compiled: Whether the entry point holds an atlas, which is a flow to compile rather than a flow to read as a program. + declared: The bodies an atlas compiles, by the identity of the `ast` node each is. + By identity rather than by name: a class beside an atlas with a method of the same + name is ordinary Python, and one skipped for sharing a spelling is one nothing + reads at all. """ entry: Path @@ -153,6 +157,7 @@ class _Whole(NamedTuple): entered: _Read | None found: list[Finding] compiled: bool + declared: frozenset[int] def _whole(flow: str | os.PathLike[str]) -> _Whole: @@ -195,6 +200,7 @@ def _whole(flow: str | os.PathLike[str]) -> _Whole: ) ], compiled=False, + declared=frozenset(), ) found: list[Finding] = [] @@ -207,16 +213,27 @@ def _whole(flow: str | os.PathLike[str]) -> _Whole: read.append(held) entered = next((one for one in read if one.where == entry), None) compiled = entered is not None and any(one.atlas for one in entered.marks) - return _Whole(entry, read, entered, found, compiled=compiled) + return _Whole( + entry, + read, + entered, + found, + compiled=compiled, + declared=frozenset( + id(one.node) for said in read for one in said.marks if one.atlas + ), + ) -def _rules(whole: _Whole, skip: frozenset[str] = frozenset()) -> tuple[Finding, ...]: +def _rules(whole: _Whole) -> tuple[Finding, ...]: """Every rule that reads a flow as a program, run over the files that parsed. + What an atlas compiles is left out: those bodies are declarations rather than programs, + and would be refused as both -- an `if` with no `elif` is a branch there, and a `while` + with no `break` is an edge back to a node. + Args: whole: What :func:`_whole` read. - skip: Functions not to read as ordinary Python -- the bodies an atlas compiles, which - are declarations rather than programs and would be refused as both. Returns: One finding per thing found, in file order. @@ -256,7 +273,7 @@ def _rules(whole: _Whole, skip: frozenset[str] = frozenset()) -> tuple[Finding, found.extend(_imports(one)) found.extend(_marks(one)) found.extend(_hooks(one, declared)) - found.extend(_functions(one, asks, skip)) + found.extend(_functions(one, asks, whole.declared)) return tuple(found) @@ -1034,15 +1051,15 @@ def forgot(self, name: str) -> None: def _functions( - read: _Read, asks: _Asks, skip: frozenset[str] = frozenset() + read: _Read, asks: _Asks, skip: frozenset[int] = frozenset() ) -> Iterator[Finding]: """Reads every function in one file for what it asks and how its loops end. Args: read: The file. asks: The interface surfaces, shared across the files of one checking. - skip: Functions not to read -- the bodies an atlas compiles, which are declarations - rather than programs. + skip: Functions not to read, by the identity of the `ast` node each is -- the bodies + an atlas compiles, which are declarations rather than programs. Yields: The findings, function by function. @@ -1056,11 +1073,11 @@ def _defined( read: _Read, asks: _Asks, inherited: dict[str, frozenset[str] | _Crew], - skip: frozenset[str] = frozenset(), + skip: frozenset[int] = frozenset(), ) -> Iterator[Finding]: """One top-level statement, read for the functions in it.""" if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - if node.name not in skip: + if id(node) not in skip: yield from _function(node, read, asks, inherited) elif isinstance(node, ast.ClassDef): for held in node.body: @@ -1089,7 +1106,7 @@ def _function( params = [*args.posonlyargs, *args.args, *args.kwonlyargs] for param in params: scope.forgot(param.arg) - kind = _annotated(param.annotation, read) + kind = _annotated(param.annotation, read.proto) crew = _crewed(param.annotation, read) if not kind else None if kind: scope.bindings[param.arg] = kind @@ -1115,20 +1132,34 @@ def _function( yield from _loops(node, scope) -def _annotated(annotation: ast.expr | None, read: _Read) -> frozenset[str] | None: - """What kinds of driven thing one annotation says a name is, or None for no answer.""" +def _annotated( + annotation: ast.expr | None, proto: Mapping[str, str] +) -> frozenset[str] | None: + """What kinds of driven thing one annotation says a name is, or None for no answer. + + Asked of the interface names rather than of a whole file, so that the compiling next + door -- which gathers those names across every file a flow holds -- asks this one + reading rather than a looser one of its own. + + Args: + annotation: The annotation. + proto: The local name of each flow-facing interface, as this flow imports them. + + Returns: + One name per interface it says the thing is, and None where it says nothing. + """ said = _unquoted(annotation) if said is None: return None if isinstance(said, ast.Subscript) and _root(said.value) == "Annotated": first = next(iter(_elements(said.slice)), None) - return _annotated(first, read) + return _annotated(first, proto) if isinstance(said, ast.Subscript) and _root(said.value) == "Optional": first = next(iter(_elements(said.slice)), None) - return _annotated(first, read) + return _annotated(first, proto) if isinstance(said, ast.BinOp) and isinstance(said.op, ast.BitOr): - left = _annotated(said.left, read) - right = _annotated(said.right, read) + left = _annotated(said.left, proto) + right = _annotated(said.right, proto) if left is None and right is None: return None # `Agent | None` is an agent to be guarded; `Agent | Session` is either, and is @@ -1136,8 +1167,8 @@ def _annotated(annotation: ast.expr | None, read: _Read) -> frozenset[str] | Non return (left or frozenset()) | (right or frozenset()) if isinstance(said, ast.Constant) and said.value is None: return frozenset() - if isinstance(said, ast.Name) and said.id in read.proto: - return frozenset({read.proto[said.id]}) + if isinstance(said, ast.Name) and said.id in proto: + return frozenset({proto[said.id]}) return None @@ -1153,7 +1184,7 @@ def _crewed(annotation: ast.expr | None, read: _Read) -> _Crew | None: return None if isinstance(said, ast.Name) and said.id in read.crews: fields = [ - (node.target.id, _annotated(node.annotation, read)) + (node.target.id, _annotated(node.annotation, read.proto)) for node in read.crews[said.id].body if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) ] @@ -1162,7 +1193,7 @@ def _crewed(annotation: ast.expr | None, read: _Read) -> _Crew | None: return None if isinstance(said, ast.Subscript) and _root(said.value) == "tuple": kinds = tuple( - _annotated(one, read) + _annotated(one, read.proto) for one in _elements(said.slice) if not (isinstance(one, ast.Constant) and one.value is Ellipsis) ) @@ -1213,7 +1244,7 @@ def _statements(body: list[ast.stmt], scope: _Scope, read: _Read, asks: _Asks) - _expression(node.value, scope) if isinstance(node.target, ast.Name): scope.forgot(node.target.id) - kind = _annotated(node.annotation, scope.read) + kind = _annotated(node.annotation, scope.read.proto) if kind: scope.bindings[node.target.id] = kind elif node.value is not None: diff --git a/src/hmz/flows/driving.py b/src/hmz/flows/driving.py index debde1e..4533aeb 100644 --- a/src/hmz/flows/driving.py +++ b/src/hmz/flows/driving.py @@ -478,7 +478,7 @@ def declares( wanted = inside(named) # Resolved here rather than by whoever is starting one, so that a name works wherever a # flow is named -- a command line, an interface, a `Runner` written by hand. - flow = find(str(flow)) + flow = find(named) # The same test `find` applies, and for the same reason: a place that cannot be read # holds no flow, which `Path.is_file` would raise about rather than answer. if not os.path.isfile(flow): # noqa: PTH113 @@ -558,8 +558,8 @@ def _compiled(named: str, read: dict[str, Any], run: Entry) -> Entry: An atlas is a flow whose body is a declaration: what it says is compiled before anything runs, and what runs is the prophecy that compiling made. So the entry point itself is never called, and what everything else holds is the walk over the prophecy instead -- - read here, where a flow is loaded, so that every way of running one gets both the - checking and the walking without knowing there are two kinds of flow. + swapped here, where a flow is loaded, so that every way of running one gets both the + compiling and the walking without knowing there are two kinds of flow. Args: named: The flow, as it was asked for. @@ -568,17 +568,63 @@ def _compiled(named: str, read: dict[str, Any], run: Entry) -> Entry: Returns: The entry point for an ordinary flow, and the walk for an atlas. - - Raises: - NotAFlow: If the atlas does not compile, which is a flow refused where it is named. """ from .atlas import ATLAS if getattr(run, ATLAS, None) is None: return run - from .stepping import walking + return _Walked(named, read, run) + + +class _Walked: + """An atlas's entry point, compiled when the run reaches it and not before. + + :func:`declares` is asked by everything that wants to know what a flow says as well as by + the two places that run one: how many agents it drives, what it can be set up with, + whether it can be picked up. Every one of those is answered off the entry point's own + annotation, and compiling the atlas to answer them would mean reading every file the flow + holds -- and, for one that does not compile, refusing a question the flow can answer. A + flow picker asking whether an atlas can be picked up would then be told no. + + So the compiling waits for the call, which is still before the first node runs: an atlas + is a flow checked before anything happens rather than one checked before anything is + read. Once compiled it is held, since this is one run of one flow. + """ - return walking(named, read, run) + def __init__(self, named: str, read: dict[str, Any], entry: Entry) -> None: + """Holds what it takes to compile one atlas, for the moment something runs it. + + Args: + named: The flow, as it was asked for. + read: What running its file left behind. + entry: The atlas's own entry point, which is never called. + """ + self._named = named + self._read = read + self._entry = entry + self._walk: Entry | None = None + # What the entry point was marked with, so that whatever reads a flow off what + # `declares` answered reads what it would have read off the entry point itself. + self.__dict__.update(entry.__dict__) + + def __call__(self, *said: Any) -> Awaitable[None] | None: + """Runs the atlas, compiling it first if this is the first call. + + Args: + said: What any flow is called with -- the agents, the task, the config for one + that takes one, and the dict a resumable flow is handed. + + Returns: + Whatever the prophecy answers with. + + Raises: + NotAFlow: If the atlas does not compile, saying each reason on a line of its own. + """ + if self._walk is None: + from .stepping import walking + + self._walk = walking(self._named, self._read, self._entry) + return self._walk(*said) def _settles(agent: Agent) -> Driven: diff --git a/src/hmz/flows/prophesying.py b/src/hmz/flows/prophesying.py index 32eeb79..08b343c 100644 --- a/src/hmz/flows/prophesying.py +++ b/src/hmz/flows/prophesying.py @@ -46,6 +46,7 @@ INPUT, Edge, Field, + Kind, Node, Prophecy, Reads, @@ -60,14 +61,16 @@ # package sharing what one of them wrote down -- not a second copy of it, kept here to drift. from .checking import ( Finding, + _annotated, # pyright: ignore[reportPrivateUsage] _elements, # pyright: ignore[reportPrivateUsage] _Mark, # pyright: ignore[reportPrivateUsage] - _names_in, # pyright: ignore[reportPrivateUsage] _Node, # pyright: ignore[reportPrivateUsage] + _parsed, # pyright: ignore[reportPrivateUsage] _Read, # pyright: ignore[reportPrivateUsage] _root, # pyright: ignore[reportPrivateUsage] _rules, # pyright: ignore[reportPrivateUsage] _tip, # pyright: ignore[reportPrivateUsage] + _unquoted, # pyright: ignore[reportPrivateUsage] _Whole, # pyright: ignore[reportPrivateUsage] _whole, # pyright: ignore[reportPrivateUsage] ) @@ -75,9 +78,7 @@ if TYPE_CHECKING: import os - from .atlas import Kind - -__all__ = ["Prophesied", "prophesied"] +__all__ = ["Prophesied", "is_atlas", "named_as", "prophesied"] #: The shapes an atlas may carry that are not models: the plain kinds a node may take and #: answer with. Anything else has fields, and a thing with fields is a model -- so that what @@ -149,13 +150,7 @@ def prophesied( ), None, ) - # The bodies an atlas compiles are declarations rather than programs, so the rules that - # read a body as a program are not run over them: an `if` with no `elif` is a branch - # here, and a `while` with no `break` is an edge back to a node. - declared = frozenset( - one.node.name for read in whole.read for one in read.marks if one.atlas - ) - found = list(_rules(whole, declared)) + found = list(_rules(whole)) for read in whole.read: found.extend(_dynamic(read)) mark = next( @@ -173,7 +168,9 @@ def prophesied( ) ) return Prophesied(tuple(found), None) - prophecy, said = _compiled(whole, mark, name or _stem(whole), through) + prophecy, said = _compiled( + whole, _gathered(whole), mark, name or _stem(whole), through + ) found.extend(said) if prophecy is not None: found.extend(_shipped(whole, prophecy)) @@ -194,44 +191,41 @@ def _shipped(whole: _Whole, prophecy: Prophecy) -> list[Finding]: prophecy: What the source compiles to now. Returns: - A `stale-prophecy` error where the two differ, and nothing where they agree or where - the flow ships none. + A `stale-prophecy` error where the two differ, and nothing where they agree, where the + flow ships none, or where what it ships is another of the atlases its file holds. """ - from . import ENTRY, PROPHECY - from .atlas import told + from . import ENTRY + from .atlas import shipped # Beside the entry point, which means the flow's own directory: a flow that is a single # file has none, and what is beside such a flow is the other flows. - if whole.entry.name != ENTRY: - return [] - at = whole.entry.parent / PROPHECY - if not at.is_file(): - return [] - held = told(at.read_bytes()) - if held is not None and held.name != prophecy.name: - return [] + held = shipped(whole.entry.parent) if whole.entry.name == ENTRY else None if held is None: + return [] + if held.prophecy is None: return [ _said( "stale-prophecy", - at, + held.at, 0, "the prophecy shipped here cannot be read back -- compile the atlas again, " "or take the file away and let each run compile it", ) ] - if digest(held) != digest(prophecy): - return [ - _said( - "stale-prophecy", - at, - 0, - f"the prophecy shipped here is {digest(held)} and this source compiles to " - f"{digest(prophecy)} -- a run walks the shipped one, so the flow does one " - "thing and reads as another", - ) - ] - return [] + if held.prophecy.name != prophecy.name: + return [] + was, now = digest(held.prophecy), digest(prophecy) + if was == now: + return [] + return [ + _said( + "stale-prophecy", + held.at, + 0, + f"the prophecy shipped here is {was} and this source compiles to {now} -- a " + "run walks the shipped one, so the flow does one thing and reads as another", + ) + ] def _dynamic(read: _Read) -> list[Finding]: @@ -266,12 +260,50 @@ def _dynamic(read: _Read) -> list[Finding]: ] +def is_atlas(flow: str | os.PathLike[str]) -> bool: + """Whether one flow is an atlas, which is what says which reading it gets. + + Read off the entry point alone rather than off everything the flow holds: the mark that + says so is on a function in that file, and whoever is asking has a choice to make before + paying for the whole reading. + + Args: + flow: The flow: its directory, or the Python file a single-file one is. + + Returns: + Whether anything in its entry point is marked `@atlas`. False for a flow that is not + there, or will not parse -- which is a flow the other reading has plenty to say about. + """ + from . import ENTRY + + at = Path(flow) + entry = at / ENTRY if at.is_dir() else at + if not entry.is_file(): + return False + read = _parsed(entry) + return not isinstance(read, Finding) and any(one.atlas for one in read.marks) + + +def named_as(under: Path, inside_: str = "") -> str: + """What one atlas is called, given where its flow is and which of them was asked for. + + Args: + under: The flow's own directory, or the file a single-file flow is. + inside_: Which of the atlases the file holds was asked for, and "" for the one it + holds under its own name. + + Returns: + The name that prophecy carries, which is what a shipped one is matched against. + """ + return inside_ or (under.stem if under.is_file() else under.name) + + def _stem(whole: _Whole) -> str: """What the atlas a file holds under its own name is called, which is the file's.""" from . import ENTRY at = whole.entry - return at.parent.name if at.name == ENTRY else at.stem + return named_as(at.parent if at.name == ENTRY else at) # --------------------------------------------------------------------------------------- @@ -292,7 +324,8 @@ class _Held(NamedTuple): nodes: The functions marked `@mind` or `@logic`, by name. atlases: The functions marked `@atlas`, by name, each beside the file it is in. subs: The atlases of other files this one named, `: ` apiece. - protos: The local names of the flow-facing interfaces, which is how an agent reads. + protos: The local name of each flow-facing interface, which is how an agent reads. + fields: The local names of pydantic's `Field`, for reading whether one is required. """ models: dict[str, ast.ClassDef] @@ -300,7 +333,8 @@ class _Held(NamedTuple): nodes: dict[str, _Node] atlases: dict[str, tuple[_Read, _Mark]] subs: dict[str, str] - protos: set[str] + protos: dict[str, str] + fields: set[str] def _gathered(whole: _Whole) -> _Held: @@ -312,13 +346,14 @@ def _gathered(whole: _Whole) -> _Held: Returns: The declarations, by name. """ - held = _Held({}, {}, {}, {}, {}, set()) + held = _Held({}, {}, {}, {}, {}, {}, set()) for read in whole.read: held.models.update(read.models) held.crews.update(read.crews) held.nodes.update(read.nodes) held.subs.update(read.subs) held.protos.update(read.proto) + held.fields.update(read.field_alias) for mark in read.marks: if mark.atlas: held.atlases[mark.node.name] = (read, mark) @@ -331,12 +366,17 @@ def _gathered(whole: _Whole) -> _Held: def _compiled( - whole: _Whole, mark: _Mark, named: str, through: tuple[tuple[str, str], ...] + whole: _Whole, + held: _Held, + mark: _Mark, + named: str, + through: tuple[tuple[str, str], ...], ) -> tuple[Prophecy | None, list[Finding]]: """One atlas's entry point read, and its body walked into a prophecy. Args: whole: The parsed files. + held: What those files declare, gathered across them. mark: The atlas being compiled. named: What to call the prophecy, which is the name the flow is asked for by. through: The atlases this one is inside, for the supernode that reaches back. @@ -345,7 +385,6 @@ def _compiled( The prophecy, or None where the reading refused it, and everything the reading found. """ where = next((one.where for one in whole.read if mark in one.marks), whole.entry) - held = _gathered(whole) found: list[Finding] = [] node = mark.node params = [*node.args.posonlyargs, *node.args.args] @@ -380,13 +419,14 @@ def _compiled( if len(params) == _AND_A_CONFIG else "" ) + answers = "" if gives == NOTHING else gives wiring = _Wiring( whole=whole, held=held, where=where, agents=agents, takes=takes, - gives="" if gives == NOTHING else gives, + gives=answers, config=config, names={params[0].arg: AGENTS, params[1].arg: INPUT} | ({params[2].arg: CONFIG} if len(params) == _AND_A_CONFIG else {}), @@ -400,7 +440,7 @@ def _compiled( Prophecy( name=named, takes=takes, - gives=wiring.gives, + gives=answers, config=config, agents=agents, nodes=tuple(wiring.nodes), @@ -481,7 +521,7 @@ def _agents( for one in crew.body: if not isinstance(one, ast.AnnAssign) or not isinstance(one.target, ast.Name): continue - if not _names_in(one.annotation) & held.protos: + if not _annotated(one.annotation, held.protos): found.append( _said( "unknown-agent", @@ -509,9 +549,24 @@ def _agents( #: the shape it holds, which may be one of the two agent kinds instead. type _Takes = list[tuple[str, str]] -#: One node's declaration: what kind it is, what it takes, what it answers with, whether a -#: run picked up inside it runs it again, and -- for a supernode -- the prophecy it is. -type _Declared = tuple["Kind", _Takes, str, bool, str] + +class _Declared(NamedTuple): + """What one thing a body calls is, read off wherever it is declared. + + Attributes: + kind: Which of the three kinds of node it is. + takes: Its parameters, as `(name, shape)` pairs, where the shape may be one of the two + agent kinds instead. + gives: The shape it answers with, and "" for one that answers with nothing. + rerun: Whether a run picked up inside it runs it again, or steps past it. + under: For a supernode, the prophecy it is, by name. "" for every other node. + """ + + kind: Kind + takes: _Takes + gives: str + rerun: bool + under: str = "" class _Wiring: @@ -654,7 +709,7 @@ def _return(self, node: ast.Return, loose: _Loose) -> None: ) return answers = node.value.id - given = self._shape_of((answers, "")) or NOTHING + given = self._shape_of(Reads(answers)) or NOTHING if given != (self.gives or NOTHING): self.found.append( _said( @@ -681,11 +736,9 @@ def _branch(self, node: ast.If, loose: _Loose) -> _Loose: read = self._branched(node.test, loose) if read is None: return loose - reads, field, truth = read - taken: _Loose = [(out_of, When(reads, field, truth)) for out_of, _ in loose] - otherwise: _Loose = [ - (out_of, When(reads, field, not truth)) for out_of, _ in loose - ] + said, truth = read + taken: _Loose = [(out_of, When(*said, truth)) for out_of, _ in loose] + otherwise: _Loose = [(out_of, When(*said, not truth)) for out_of, _ in loose] return [*self._block(node.body, taken), *self._block(node.orelse, otherwise)] def _loop(self, node: ast.While, loose: _Loose) -> _Loose: @@ -718,13 +771,12 @@ def _loop(self, node: ast.While, loose: _Loose) -> _Loose: if read is None: return loose head = loose[0][0] - reads, field, truth = read - opens: _Loose = [(head, When(reads, field, truth))] - inside = self._block(node.body, opens) - for out_of, when in inside: + said, truth = read + opens: _Loose = [(head, When(*said, truth))] + for out_of, when in self._block(node.body, opens): self.edges.append(Edge(out_of, head, when)) self._endless(node, head) - ends: _Loose = [(head, When(reads, field, not truth))] + ends: _Loose = [(head, When(*said, not truth))] return ends def _endless(self, node: ast.While, head: str) -> None: @@ -741,7 +793,7 @@ def _endless(self, node: ast.While, head: str) -> None: for one in said.targets if isinstance(one, ast.Name) } - reading = next((one for one in self.nodes if one.at == head), None) + reading = self._above(head) if reading is not None and not wrote & {one.reads for one in reading.takes}: self.found.append( _said( @@ -753,7 +805,7 @@ def _endless(self, node: ast.While, head: str) -> None: ) ) - def _branched(self, test: ast.expr, loose: _Loose) -> tuple[str, str, bool] | None: + def _branched(self, test: ast.expr, loose: _Loose) -> tuple[Reads, bool] | None: """What one branch reads, and whether the nodes above it may be branched on. Args: @@ -761,8 +813,8 @@ def _branched(self, test: ast.expr, loose: _Loose) -> tuple[str, str, bool] | No loose: The ends arriving at the branch. Returns: - The name read, the field read off it, and whether the first way out is the one - taken when that reads as true. None where the branch is refused. + What the branch reads, and whether the first way out is the one taken when that + reads as true. None where the branch is refused. """ said = test.operand if isinstance(test, ast.UnaryOp) else test truth = not _is_not(test) @@ -810,7 +862,7 @@ def _branched(self, test: ast.expr, loose: _Loose) -> tuple[str, str, bool] | No ) ) return None - return (*read, truth) + return read, truth def _above(self, at: str) -> Node | None: """The node of that id, or None for the way into the prophecy.""" @@ -928,9 +980,7 @@ def _noded(self, call: ast.Call, called: str, held: _Node) -> _Declared | None: params = [*held.node.args.posonlyargs, *held.node.args.args] takes: _Takes = [] for at, one in enumerate(params): - agent = bool( - one.annotation and _names_in(one.annotation) & self.held.protos - ) + agent = bool(_annotated(one.annotation, self.held.protos)) if agent and at == 0 and held.kind == "mind": takes.append((one.arg, ONE_AGENT)) continue @@ -984,7 +1034,9 @@ def _noded(self, call: ast.Call, called: str, held: _Node) -> _Declared | None: ) return None kind: Kind = "mind" if held.kind == "mind" else "logic" - return kind, takes, "" if gives == NOTHING else gives, held.rerun, "" + return _Declared( + kind, takes, "" if gives == NOTHING else gives, rerun=held.rerun + ) def _supernode(self, call: ast.Call, called: str) -> _Declared | None: """One supernode: a whole atlas, compiled into the prophecy reaching for it. @@ -1028,12 +1080,12 @@ def _supernode(self, call: ast.Call, called: str) -> _Declared | None: ) ) return None - return ( + return _Declared( "atlas", [("agents", THE_AGENTS), ("said", under.takes)], under.gives, - True, - named, + rerun=True, + under=named, ) def _under(self, call: ast.Call, called: str, named: str) -> Prophecy | None: @@ -1052,7 +1104,7 @@ def _under(self, call: ast.Call, called: str, named: str) -> Prophecy | None: read, mark = beside if self._circular(call, named, _who(read.where, mark.name)): return None - made, found = _compiled(self.whole, mark, named, self.through) + made, found = _compiled(self.whole, self.held, mark, named, self.through) self.found.extend(found) return made from . import ENTRY, find, inside @@ -1151,11 +1203,11 @@ def _arguments( return None if not self._fits(call, called, param, shape, read): return None - reads.append(Reads(*read)) + reads.append(read) return reads def _fits( - self, call: ast.Call, called: str, param: str, shape: str, read: tuple[str, str] + self, call: ast.Call, called: str, param: str, shape: str, read: Reads ) -> bool: """Whether what flows into one parameter is what that parameter takes. @@ -1196,7 +1248,7 @@ def _fits( return True def _agented( - self, call: ast.Call, called: str, param: str, shape: str, read: tuple[str, str] + self, call: ast.Call, called: str, param: str, shape: str, read: Reads ) -> bool: """Whether what flows into an agent's place is one of the run's own agents. @@ -1238,7 +1290,7 @@ def _agented( # -- the names a body binds and reads -------------------------------------------- - def _reads(self, node: ast.expr) -> tuple[str, str] | None: + def _reads(self, node: ast.expr) -> Reads | None: """One name a body reads, and the field read off it. Args: @@ -1249,12 +1301,12 @@ def _reads(self, node: ast.expr) -> tuple[str, str] | None: this is not a name at all. """ if isinstance(node, ast.Name): - return (self.names.get(node.id, node.id), "") + return Reads(self.names.get(node.id, node.id)) if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name): - return (self.names.get(node.value.id, node.value.id), node.attr) + return Reads(self.names.get(node.value.id, node.value.id), node.attr) return None - def _shape_of(self, read: tuple[str, str]) -> str | None: + def _shape_of(self, read: Reads) -> str | None: """What one name, or one field of it, holds. Args: @@ -1338,16 +1390,17 @@ def _shape(annotation: ast.expr | None, held: _Held) -> str | None: """ if annotation is None: return None + if isinstance(annotation, ast.Constant) and annotation.value is None: + return NOTHING + # A quoted annotation is the annotation: a flow written under `from __future__ import + # annotations` and one written without it declare the same node. + said = _unquoted(annotation) + if said is None: + return None + if said is not annotation: + return _shape(said, held) + annotation = said if isinstance(annotation, ast.Constant): - if annotation.value is None: - return NOTHING - # A quoted annotation is the annotation: a flow written under `from __future__ - # import annotations` and one written without it declare the same node. - if isinstance(annotation.value, str): - try: - return _shape(ast.parse(annotation.value, mode="eval").body, held) - except SyntaxError: - return None return None if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr): sides = [_shape(annotation.left, held), _shape(annotation.right, held)] @@ -1412,15 +1465,25 @@ def _fields(model: ast.ClassDef, held: _Held) -> list[Field]: if name.startswith("_") or _root(one.annotation) == "ClassVar": continue said = [was for was in said if was.name != name] - said.append(Field(name, _wrote(one.annotation), required=_required(one.value))) + said.append( + Field(name, _wrote(one.annotation), required=_required(one.value, held)) + ) return said -def _required(value: ast.expr | None) -> bool: - """Whether a field with that default refuses to be built without being given one.""" +def _required(value: ast.expr | None, held: _Held) -> bool: + """Whether a field with that default refuses to be built without being given one. + + Args: + value: What the field was declared with, and None where it was declared with nothing. + held: What the flow's files declare, for what each of them calls pydantic's `Field`. + + Returns: + Whether a model of it cannot be built without being handed one. + """ if value is None: return True - if isinstance(value, ast.Call) and _tip(value.func) == "Field": + if isinstance(value, ast.Call) and _root(value.func) in held.fields: named = {one.arg for one in value.keywords} return not (value.args or named & {"default", "default_factory"}) return False @@ -1484,7 +1547,7 @@ def _is_not(node: ast.expr) -> bool: return isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not) -def _names(read: tuple[str, str]) -> str: +def _names(read: Reads) -> str: """How one name and the field read off it read in a finding.""" reads, field = read said = { diff --git a/src/hmz/flows/stepping.py b/src/hmz/flows/stepping.py index f312aff..b4984c6 100644 --- a/src/hmz/flows/stepping.py +++ b/src/hmz/flows/stepping.py @@ -24,11 +24,11 @@ from __future__ import annotations +from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, cast -from . import PROPHECY -from .atlas import AGENTS, ATLAS, CONFIG, INPUT, Reads, digest, told +from .atlas import AGENTS, CONFIG, INPUT, Node, Reads, digest, shipped if TYPE_CHECKING: import os @@ -37,7 +37,7 @@ from pydantic import BaseModel from .agent import Agent - from .atlas import Edge, Node, Prophecy + from .atlas import Edge, Prophecy from .driving import Entry __all__ = ["walking"] @@ -62,17 +62,17 @@ def walking( ) -> Entry: """Compiles one atlas, and answers with something that runs the prophecy. - Called where a flow is loaded rather than where it is run, which is what makes an atlas - a flow checked before anything happens: a body that does not compile is a flow refused - at the line that named it, with everything wrong with it said at once. + Called where a flow is about to be run rather than where it is merely read, which is + what makes an atlas a flow checked before anything happens: a body that does not compile + is a flow refused before its first node, with everything wrong with it said at once. Args: flow: The atlas, as it was asked for -- which is also which of the ones its file holds is wanted. inside: What running the flow's file left behind, which is where the nodes are. entry: The atlas's own entry point, whose body is the declaration that was compiled - and is therefore never called. What it carries is copied onto the answer, so that - everything reading a flow off its entry point goes on reading this one. + and is therefore never called. What it was marked with is carried onto the answer, so + that everything reading a flow off its entry point goes on reading this one. Returns: Something to call the way any flow is called -- the agents, the task, the config for @@ -81,17 +81,19 @@ def walking( Raises: NotAFlow: If the atlas does not compile, saying each reason on a line of its own. """ - from . import ENTRY, Flow, find + import functools + from . import inside as which + from . import reading from .driving import NotAFlow - from .prophesying import prophesied + from .prophesying import named_as, prophesied - at = Path(find(str(flow))) - under = at.parent if at.name == ENTRY else at - wanted = which(str(flow)) or (under.name if at.name == ENTRY else at.stem) + named = str(flow) + under = Path(reading(named)) + wanted = named_as(under, which(named)) prophecy = _shipped(under, wanted) if prophecy is None: - held = prophesied(under, name=which(str(flow))) + held = prophesied(under, name=which(named)) if held.prophecy is None: why = "\n".join( f" {one.where}:{one.line}: {one.code}: {one.said}" @@ -100,18 +102,19 @@ def walking( ) raise NotAFlow(f"{flow}: the atlas does not compile\n{why}") prophecy = held.prophecy + walked = prophecy def running(agents: Any, task: Any, *said: Any) -> Any: # A resumable flow is handed its state last and its config before it, and an atlas # is always resumable: what a run of one has done is which of its nodes answered. state: dict[str, Any] = said[-1] if said else {} config = said[0] if len(said) > 1 else None - return _stepped(prophecy, inside, agents, task, config, state) + return _stepped(walked, inside, agents, task, config, state) - marked = getattr(entry, "__humanize_flow__", None) - running.__humanize_flow__ = marked if isinstance(marked, Flow) else Flow() # pyright: ignore[reportFunctionMemberAccess] - setattr(running, ATLAS, getattr(entry, ATLAS, None)) - return running + # Whatever the entry point was marked with, and not the two marks known today: both + # `flow` and `atlas` set theirs into the function's own `__dict__`, which is exactly + # what this copies -- so a third mark added later travels without this line moving. + return functools.update_wrapper(running, entry, assigned=(), updated=("__dict__",)) def _shipped(under: Path, wanted: str) -> Prophecy | None: @@ -119,15 +122,15 @@ def _shipped(under: Path, wanted: str) -> Prophecy | None: Preferred over compiling the atlas again: the compiling is where an atlas is refused, and a repository that has been through it has an answer worth carrying. A directory - holds one prophecy and a file may hold several atlases, so the one shipped is the one - it is named after -- and the rest are compiled where they are asked for. + holds one prophecy and a file may hold several atlases, so the one shipped is the one it + is named after -- and the rest are compiled where they are asked for. Args: - under: The flow's own directory. + under: The flow's own directory, or the file a single-file flow is, which ships none. wanted: Which of the atlases the file holds is being run. Returns: - The prophecy, or None where the flow ships none. + The prophecy, or None where the flow ships none, or ships one for another atlas. Raises: NotAFlow: If it ships one that cannot be read back. Refused rather than compiled @@ -136,16 +139,60 @@ def _shipped(under: Path, wanted: str) -> Prophecy | None: """ from .driving import NotAFlow - at = under / PROPHECY - if not at.is_file(): - return None - held = told(at.read_bytes()) + held = shipped(under) if held is None: + return None + if held.prophecy is None: raise NotAFlow( - f"{at}: the prophecy shipped here cannot be read -- compile the atlas again, " - "or take the file away and let the run compile it" + f"{held.at}: the prophecy shipped here cannot be read -- compile the atlas " + "again, or take the file away and let the run compile it" ) - return held if held.name == wanted else None + return held.prophecy if held.prophecy.name == wanted else None + + +@dataclass(slots=True) +class _Walk: + """One prophecy being walked, and everything every step of it is against. + + Held once rather than handed down: what changes as a run goes is which node it is at and + what each name holds, and everything else is the same at every step. A supernode makes + another of these -- its own prophecy, its own file, its own agents -- and keeps what the + whole run shares. + + Attributes: + prophecy: The compiled atlas being walked. + inside: What running the file it was compiled from left behind, which is where the + functions its nodes are get looked up. + agents: The run's agents, by the name this prophecy calls each. + state: The whole run's state, which is where it says what node it stopped inside. + kept: What each visit to a node that has answered answered, for the whole run. + under: What this prophecy's visits are written down beneath, "" for the outermost. + beside: What each flow reached by name left behind when it was read, for the whole + run. Read once: a run walks one prophecy, and a sub-flow re-read between two rounds + of a loop would be new code running under a graph that had already been settled. + nodes: The prophecy's nodes by id, and its ways out by the node each leaves. Built + once rather than scanned at every step, a prophecy being what it is for the length + of the walk. + ways: As above. + """ + + prophecy: Prophecy + inside: Mapping[str, Any] + agents: dict[str, Agent] + state: dict[str, Any] + kept: dict[str, Any] + under: str + beside: dict[str, Mapping[str, Any]] + nodes: dict[str, Node] = field(init=False) + ways: dict[str, tuple[Edge, ...]] = field(init=False) + + def __post_init__(self) -> None: + """Reads the prophecy into what a step of the walk asks it.""" + self.nodes = {one.at: one for one in self.prophecy.nodes} + self.ways = { + at: self.prophecy.out_of(at) + for at in {"", *(one.out_of for one in self.prophecy.edges)} + } def _stepped( @@ -176,69 +223,52 @@ def _stepped( # that kept its name is not thereby the node it was. state.clear() state[_PROPHECY] = written - kept: dict[str, Any] = state.setdefault(_DONE, {}) - return _walked( - prophecy, - inside, - {one: getattr(agents, one) for one in prophecy.agents}, - given, - config, - state, - kept, - "", + walk = _Walk( + prophecy=prophecy, + inside=inside, + agents={one: getattr(agents, one) for one in prophecy.agents}, + state=state, + kept=state.setdefault(_DONE, {}), + under="", + beside={}, ) + return _walked(walk, given, config) -def _walked( - prophecy: Prophecy, - inside: Mapping[str, Any], - agents: dict[str, Agent], - given: Any, - config: BaseModel | None, - state: dict[str, Any], - kept: dict[str, Any], - under: str, -) -> Any: +def _walked(walk: _Walk, given: Any, config: BaseModel | None) -> Any: """Walks one prophecy from its way in to its way out. Args: - prophecy: The compiled atlas, or one of the ones under it. - inside: What running the file it was compiled from left behind. - agents: The run's agents, by the name this prophecy calls each. + walk: The prophecy being walked, and what every step of it is against. given: What it was called with. config: What the run was set up with. - state: The whole run's state, for where it stopped. - kept: What each node that has answered answered, by visit. - under: What this prophecy's visits are written down beneath, "" for the outermost. Returns: - What the prophecy answers with, which is what its last node answered. + What the prophecy answers with, which is what the `return` it left by named. """ - bound: dict[str, Any] = {AGENTS: agents, INPUT: given, CONFIG: config} + bound: dict[str, Any] = {AGENTS: walk.agents, INPUT: given, CONFIG: config} seen: dict[str, int] = {} at = "" while True: - edge = _way(prophecy, at, bound) - node = None if edge is None else prophecy.node(edge.into) + edge = _way(walk, at, bound) + node = None if edge is None else walk.nodes.get(edge.into) if node is None: # What the `return` named, and not whatever the last node happened to say: a # body may answer with something it bound three nodes ago. return bound.get(edge.answers) if edge is not None else None seen[node.at] = visit = seen.get(node.at, 0) + 1 - held = f"{under}{node.at}{_VISIT}{visit}" - answered = _answered( - prophecy, inside, agents, config, state, kept, bound, node, held - ) + held = f"{walk.under}{node.at}{_VISIT}{visit}" + answered = _answered(walk, bound, node, held) if node.binds: bound[node.binds] = answered at = node.at -def _way(prophecy: Prophecy, at: str, bound: dict[str, Any]) -> Edge | None: +def _way(walk: _Walk, at: str, bound: dict[str, Any]) -> Edge | None: """Which way out of one node this run takes. Args: - prophecy: The prophecy. + walk: The prophecy being walked. at: The node it is leaving, and "" for the way in. bound: What each name holds now. @@ -247,7 +277,7 @@ def _way(prophecy: Prophecy, at: str, bound: dict[str, Any]) -> Edge | None: answers with is on it; None is a node nothing leads on from, which nothing that compiled can be. """ - for edge in prophecy.out_of(at): + for edge in walk.ways.get(at, ()): when = edge.when if ( when is None @@ -257,26 +287,11 @@ def _way(prophecy: Prophecy, at: str, bound: dict[str, Any]) -> Edge | None: return None -def _answered( - prophecy: Prophecy, - inside: Mapping[str, Any], - agents: dict[str, Agent], - config: BaseModel | None, - state: dict[str, Any], - kept: dict[str, Any], - bound: dict[str, Any], - node: Node, - held: str, -) -> Any: +def _answered(walk: _Walk, bound: dict[str, Any], node: Node, held: str) -> Any: """What one node answers with: what it answered last time, or what it answers now. Args: - prophecy: The prophecy the node is in. - inside: What running the file it was compiled from left behind. - agents: The run's agents, by name. - config: What the run was set up with. - state: The whole run's state, for where it stopped. - kept: What each node that has answered answered. + walk: The prophecy being walked. bound: What each name holds now. node: The node. held: What this visit to it is written down under. @@ -285,43 +300,31 @@ def _answered( Its answer, rebuilt through the shape it declared where this is a visit the run is picking up rather than one it is taking. """ - if held in kept: - return _rebuilt(kept[held], node.gives, inside) - if held == state.get(_AT) and not node.rerun: + if held in walk.kept: + return _rebuilt(walk.kept[held], node.gives, walk.inside) + if held == walk.state.get(_AT) and not node.rerun: # Where the last run stopped, in a node that says it is not to be run again: it had # its effect before anything could interrupt it, so the run steps past. It answers # with nothing -- the compiling refuses one that does not -- so there is nothing for # what comes next to be missing. - kept[held] = None - _saved(state) + walk.kept[held] = None + _saved(walk.state) return None - state[_AT] = held - _saved(state) - answered = _ran(prophecy, inside, agents, state, kept, bound, node, held) - kept[held] = _written(answered) - _saved(state) - _ = config + # Written down before the node runs and saved once: `State` saves itself as it is + # written into, and what goes into `kept` below is a change inside a value it holds and + # cannot see -- which is the one that has to ask. + walk.state[_AT] = held + answered = _ran(walk, bound, node, held) + walk.kept[held] = _written(answered) + _saved(walk.state) return answered -def _ran( - prophecy: Prophecy, - inside: Mapping[str, Any], - agents: dict[str, Agent], - state: dict[str, Any], - kept: dict[str, Any], - bound: dict[str, Any], - node: Node, - held: str, -) -> Any: +def _ran(walk: _Walk, bound: dict[str, Any], node: Node, held: str) -> Any: """Runs one node for real: a turn, a Python function, or a whole prophecy. Args: - prophecy: The prophecy the node is in. - inside: What running the file it was compiled from left behind. - agents: The run's agents, by name. - state: The whole run's state. - kept: What each node that has answered answered. + walk: The prophecy being walked. bound: What each name holds now. node: The node. held: What this visit to it is written down under. @@ -337,34 +340,21 @@ def _ran( said = [_read(bound, one) for one in node.takes] if node.kind == "atlas": - return _supernode(prophecy, inside, agents, state, kept, node, held, said) - call = inside.get(node.calls) + return _supernode(walk, node, held, said) + call = walk.inside.get(node.calls) if not callable(call): raise NotAFlow( - f"{prophecy.name}: nothing in the flow is called {node.calls!r} -- the " + f"{walk.prophecy.name}: nothing in the flow is called {node.calls!r} -- the " "prophecy was compiled from a file that has since been rewritten" ) return call(*said) -def _supernode( - prophecy: Prophecy, - inside: Mapping[str, Any], - agents: dict[str, Agent], - state: dict[str, Any], - kept: dict[str, Any], - node: Node, - held: str, - said: list[Any], -) -> Any: +def _supernode(walk: _Walk, node: Node, held: str, said: list[Any]) -> Any: """Runs one supernode, which is a whole prophecy inside this one. Args: - prophecy: The prophecy the node is in. - inside: What running the file it was compiled from left behind. - agents: The run's agents, by name. - state: The whole run's state. - kept: What each node that has answered answered. + walk: The prophecy the node is in. node: The node. held: What this visit to it is written down under, which its own nodes go beneath. said: What it was handed -- the agents, then the one shape it takes. @@ -379,23 +369,34 @@ def _supernode( from . import find, loaded from .driving import NotAFlow - under = prophecy.under(node.under) + under = walk.prophecy.under(node.under) if under is None: - raise NotAFlow(f"{prophecy.name}: nothing under it is called {node.under!r}") + raise NotAFlow( + f"{walk.prophecy.name}: nothing under it is called {node.under!r}" + ) # Beside it or elsewhere: a supernode of this flow's own is in the file this prophecy # was compiled from, and one reached by name is a flow of its own, run to be read as any - # flow is. Read at the call rather than held, for the reason a called flow is: an atlas - # rewritten between two rounds of a loop is the atlas that runs next. - beside = inside if node.calls in inside else loaded(find(node.calls)) + # flow is. Read once for the run rather than once a visit: the graph was settled before + # the run started, and a file re-read between two rounds of a loop would be new code + # running under a shape that had already been agreed. + if node.calls in walk.inside: + beside = walk.inside + elif node.calls not in walk.beside: + walk.beside[node.calls] = beside = loaded(find(node.calls)) + else: + beside = walk.beside[node.calls] return _walked( - under, - beside, - {one: agents[one] for one in under.agents}, + _Walk( + prophecy=under, + inside=beside, + agents={one: walk.agents[one] for one in under.agents}, + state=walk.state, + kept=walk.kept, + under=f"{held}{_UNDER}", + beside=walk.beside, + ), said[1], None, - state, - kept, - f"{held}{_UNDER}", ) diff --git a/src/hmz/sdk/flows.py b/src/hmz/sdk/flows.py index e392952..2228be5 100644 --- a/src/hmz/sdk/flows.py +++ b/src/hmz/sdk/flows.py @@ -227,17 +227,23 @@ def check( named: The flow, by the name `-f` takes or by a path. static: Only the reading that executes nothing. + An atlas gets the stricter of the two static readings, which is the compiling: its + body is a declaration rather than a program. It is chosen here rather than deeper + down because this is where both halves of the name are held, and which of the + atlases a file holds was asked for is half of it. + Returns: Every finding, the static reading's first and nothing said twice: a finding the static reading already made is not repeated off the live model. """ - from pathlib import Path - - from hmz.flows import ENTRY, checked, find, inside, proved - - at = Path(find(str(named))) - whole = at.parent if at.name == ENTRY else at - found = list(checked(whole)) + from hmz.flows import checked, inside, is_atlas, prophesied, proved, reading + + whole = reading(str(named)) + found = list( + prophesied(whole, name=inside(str(named))).findings + if is_atlas(whole) + else checked(whole) + ) if static or any(one.severity == "error" for one in found): return tuple(found) proof = proved(whole, name=inside(str(named)), scenarios=()) @@ -260,13 +266,9 @@ def prophecy(self, named: str | os.PathLike[str]) -> Prophecy | None: The prophecy, or None for a flow that is not an atlas or does not compile -- which :meth:`check` says the reasons for. """ - from pathlib import Path - - from hmz.flows import ENTRY, find, inside, prophesied + from hmz.flows import inside, prophesied, reading - at = Path(find(str(named))) - whole = at.parent if at.name == ENTRY else at - return prophesied(whole, name=inside(str(named))).prophecy + return prophesied(reading(str(named)), name=inside(str(named))).prophecy def foretell(self, named: str | os.PathLike[str]) -> str: """Compiles an atlas and writes the prophecy into its own directory. @@ -289,18 +291,20 @@ def foretell(self, named: str | os.PathLike[str]) -> str: """ from pathlib import Path - from hmz.flows import ENTRY, PROPHECY, NotAFlow, find, kept + from hmz.flows import PROPHECY, NotAFlow, at, kept held = self.prophecy(named) if held is None: raise NotAFlow(f"{named}: not an atlas that compiles -- hmz check says why") - at = Path(find(str(named))) - if at.name != ENTRY: + # "" for a flow that is a single file, which has no directory of its own: what is + # beside such a flow is the other flows, and none of it came with this one. + beside = at(str(named)) + if not beside: raise NotAFlow( f"{named}: a flow that is one file has no directory to ship a prophecy " "in -- make it a directory with an __init__.py in it" ) - into = at.parent / PROPHECY + into = Path(beside) / PROPHECY into.write_bytes(kept(held)) return str(into) diff --git a/tests/test_prophesying.py b/tests/test_prophesying.py index 7b52784..1d12375 100644 --- a/tests/test_prophesying.py +++ b/tests/test_prophesying.py @@ -553,3 +553,40 @@ def run(agents: Agents, task: str) -> None: again = inner(agents, draft) ''' assert "shape-mismatch" in _codes(tmp_path, body) + + +def test_the_atlas_a_name_asks_for_is_the_one_that_is_read(tmp_path: Path) -> None: + """A file may hold several, and `official/review:pass` is which of them was meant.""" + body = '''@atlas(name="pass") +def only(agents: Agents, task: str) -> None: + """The only atlas the file holds, and it has a name of its own.""" + draft = write(agents.writer, task) +''' + at = written(tmp_path, "one", HEAD + body) + + assert prophesied(at, name="pass").prophecy is not None + # And the file's own name asks for one it does not hold, which it says and lists. + said = prophesied(at).findings + assert [one.code for one in said] == ["not-an-atlas"] + assert "'pass'" in said[0].said + + +def test_python_beside_an_atlas_is_read_as_python(tmp_path: Path) -> None: + """The bodies left out are the ones compiled, and not everything spelled like one.""" + body = '''class Helper: + """Something beside the atlas with a method the atlas's name also uses.""" + + def run(self) -> None: + """A loop nothing inside can end, which is a thing to be told about.""" + while True: + pass + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) +''' + at = written(tmp_path, "one", HEAD + body) + + assert "sleeping-loop" in {one.code for one in checked(at)} From 15dc2ed0d4f2dc11746d6337c5799a4cc400b268 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:03:52 +0000 Subject: [PATCH 15/18] docs(flows): say what the cleanup pass moved The prophecy gained a config and its ways out gained what the run answers with; `reading`, `is_atlas`, `named_as` and `shipped` are the four rules the pass put in one place apiece. All of it was already true of the code. Co-Authored-By: Claude Opus 5 (1M context) --- docs/reference/flows.md | 13 +++++++------ src/hmz/flows/SPEC.md | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/reference/flows.md b/docs/reference/flows.md index 13e1ec5..6670230 100644 --- a/docs/reference/flows.md +++ b/docs/reference/flows.md @@ -1269,12 +1269,13 @@ if held.prophecy is not None: ``` `prophesied` answers with `(findings, prophecy)`, the prophecy being None where anything was -an error. A `Prophecy` holds its `name`, what it `takes` and `gives`, the `agents` it drives, -its `nodes`, its `edges`, the `shapes` that flow along them, and one `Prophecy` per supernode -under it. A `Node` carries `at` (its id — the callee, and `:2`, `:3` for the second and third -call to it), `kind`, `calls`, `takes`, `binds`, `gives`, `rerun` and `under`. An `Edge` -carries `out_of`, `into` and a `When` or None; `""` is the way in at one end and the way out -at the other. +an error. A `Prophecy` holds its `name`, what it `takes`, `gives` and can be set up with as a +`config`, the `agents` it drives, its `nodes`, its `edges`, the `shapes` that flow along them, +and one `Prophecy` per supernode under it. A `Node` carries `at` (its id — the callee, and +`:2`, `:3` for the second and third call to it), `kind`, `calls`, `takes`, `binds`, `gives`, +`rerun` and `under`. An `Edge` carries `out_of`, `into`, a `When` or None, and — for a way out +of the prophecy — the name the run `answers` with; `""` is the way in at one end and the way +out at the other. `canonical` is one line of JSON with everything ordered by what it is rather than where it was written, so two readings of one atlas are the same bytes; `digest` is what a run picked up diff --git a/src/hmz/flows/SPEC.md b/src/hmz/flows/SPEC.md index 9b5e114..32a8d12 100644 --- a/src/hmz/flows/SPEC.md +++ b/src/hmz/flows/SPEC.md @@ -110,6 +110,9 @@ def inside(named_: str) -> str: ... def about(named_: str) -> str: ... +def reading(named_: str) -> str: ... + + def foretold(named_: str) -> str: ... @@ -138,6 +141,10 @@ def __getattr__(name: str) -> object: ... - What a flow says about itself MUST be the first line of its docstring where the decorator was not told one, and for a file that is one flow MUST fall back to the file's own docstring: a file that is one flow is documented as that flow. +- What a reading of a flow is pointed at MUST be worked out in one place, and MUST NOT be + what runs it: both readings take the whole of a flow -- the directory where there is one, + so that what the entry point imports beside it is read too, and the file where there is + not -- while what runs it is the entry point. Two rules for that is two rules to drift. - A name MUST resolve to the `__init__.py` of the directory called that, else to the `.py` file called that. A path given outright MAY be either, and MUST be taken in both shapes: a path with the extension left off is how a single-file flow is written down everywhere a @@ -620,6 +627,7 @@ class Edge(NamedTuple): out_of: str into: str when: When | None = None + answers: str = "" class Prophecy(NamedTuple): @@ -672,6 +680,14 @@ def kept(prophecy: Prophecy) -> bytes: ... def told(said: bytes) -> Prophecy | None: ... + + +class Shipped(NamedTuple): + at: Path + prophecy: Prophecy | None + + +def shipped(under: str | os.PathLike[str]) -> Shipped | None: ... ``` What an atlas is written in, and the prophecy it compiles to. A flow is a Python file that may @@ -720,6 +736,13 @@ atlas is the other bargain, and this is the vocabulary of both halves. those bytes runs what they say, which is the trust a flowverse already has; what MUST be added is the check that what came back is a prophecy at all, so that a file which is merely corrupt is refused rather than walked. +- Where a shipped prophecy is, whether it is there, and what it takes to read it back MUST be + one rule rather than one per reader. What to do about a file that will not read back MUST + be each reader's own -- a run refuses it and a checking says so -- but a flow that is one + file having nowhere to ship anything MUST be answered the same way wherever it is asked. +- What each of the atlases in one prophecy is called MUST be worked out in one place, since + a directory ships one prophecy and a file may hold several atlases: which of them a shipped + one is for is read by comparing that name. ## `prophesying.py` @@ -729,6 +752,12 @@ class Prophesied(NamedTuple): prophecy: Prophecy | None +def is_atlas(flow: str | os.PathLike[str]) -> bool: ... + + +def named_as(under: Path, inside_: str = "") -> str: ... + + def prophesied( flow: str | os.PathLike[str], *, @@ -741,6 +770,9 @@ def prophesied( Compiling an atlas: the reading that holds a body to the narrower Python it is written in, and turns what it declared into the prophecy a run walks. +- Which reading a flow gets MUST be decidable without paying for either, which is + `is_atlas`: the mark that says a flow is an atlas is on a function in its entry point, and + whoever is choosing has a choice to make before reading everything the flow holds. - It MUST NOT import or execute anything of the atlas it reads, for the reason `checking.py` MUST NOT: the atlas most worth compiling is one nobody has read yet, and a compiler that ran what it was compiling would be the attack it exists to catch. From 9f9c7ae696cbdcff783d6e7876fba100502196a8 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:12:21 +0000 Subject: [PATCH 16/18] fix(flows): fifteen defects a max-effort review of the atlas turned up The worst was mine and it was a hole: `hmz check --static` promises to execute nothing of the flow it is pointed at, and the prophecy a flowverse ships is a pickle -- so a `prophecy.pkl` naming `os.system` ran it. Reading one is held to the tuples a prophecy is made of and nothing else, which is checked here with a payload that no longer fires. The rest, in the order they would have bitten: - A body that rebinds what the atlas was called with compiled into a graph that reads the original, with the rebinding bound to a name nothing read. - A name bound in one arm of a branch was readable on the path that skipped it, so a node was handed None where the compile is meant to rule that out. - A `sub()` bound under the name its own flow uses ran the sub-prophecy against the caller's module: no error, wrong answer. - A run that had finished replayed every kept answer and did nothing, so every `hmz exec` after the first was a no-op with a new task silently ignored. - `_required` read the root of a dotted name, so `pydantic.Field(...)` made every field optional and the edge checks that turn on it went quiet. - A loop took the node above it for its head without asking whether that node answers the name the test reads, so a loop nothing could end compiled. - A supernode wrote its own key where the run says which node it stopped in, so a `rerun=False` node inside one had its effect twice. - `class Draft(pydantic.BaseModel)` was not read as a model, refusing a legal atlas; the dispatch to the compiling was per-file, so an ordinary flow beside an atlas was reported as one that is not an atlas; a stale prophecy could never be re-shipped, since the finding about it discarded the graph that would have replaced it; and a walrus-guarded answer read as unguarded. Co-Authored-By: Claude Opus 5 (1M context) --- docs/guide/atlas.md | 6 +-- docs/guide/checking-flows.md | 10 ++-- docs/guide/writing-a-flow.md | 5 +- src/hmz/cli/check.py | 14 ++---- src/hmz/flows/__init__.py | 34 +++++++++++-- src/hmz/flows/atlas.py | 48 +++++++++++++++--- src/hmz/flows/checking.py | 39 ++++++++++++-- src/hmz/flows/prophesying.py | 98 ++++++++++++++++++++++++++++++++---- src/hmz/flows/proving.py | 18 +++++++ src/hmz/flows/stepping.py | 38 ++++++++++---- src/hmz/sdk/flows.py | 7 ++- tests/test_prophesying.py | 2 +- 12 files changed, 258 insertions(+), 61 deletions(-) diff --git a/docs/guide/atlas.md b/docs/guide/atlas.md index f828410..6487178 100644 --- a/docs/guide/atlas.md +++ b/docs/guide/atlas.md @@ -49,13 +49,13 @@ class Verdict(BaseModel): @mind def write(agent: Agent, task: str) -> Draft: """One turn of writing.""" - return agent(task, shape=Draft) + return agent(task, schema=Draft) @mind def review(agent: Agent, draft: Draft) -> Verdict: """One turn of reviewing.""" - return agent(f"review this:\n\n{draft.text}", shape=Verdict) + return agent(f"review this:\n\n{draft.text}", schema=Verdict) @logic @@ -78,7 +78,7 @@ def run(agents: Agents, task: str) -> None: It is run exactly as any other flow is: ```sh -hmz exec -f review_loop -a writer=claude/sonnet:high -a reviewer=codex/gpt-5.6-sol "$(cat TASK.md)" +hmz exec -f review_loop -a claude/sonnet:high -a codex/gpt-5.6-sol "$(cat TASK.md)" ``` ## The two kinds of node diff --git a/docs/guide/checking-flows.md b/docs/guide/checking-flows.md index 26a96a5..85e5009 100644 --- a/docs/guide/checking-flows.md +++ b/docs/guide/checking-flows.md @@ -1,7 +1,8 @@ # Checking a flow Check a flow before anything runs it: a static reading that executes nothing, then the flow -driven by stubs against a clock. Together they catch the mistakes that otherwise surface +loaded in a subprocess held to a clock — and, when you ask for it, driven by stubs through the +worlds worth asking of a loop. Together they catch the mistakes that otherwise surface hours into a run — the loop nothing can end, the field read off an answer that failed, the name the interface does not answer to. @@ -44,11 +45,12 @@ wrote, one fetched off the internet, one about to be forked: hmz check --static somebody-elses/flow ``` -## The reading that runs it against the worst day +## The reading that loads it, and the one that drives it Without `--static`, the flow is also loaded — in a subprocess held to a clock, never in your -process — and its live config model is read. The same machinery is a library, and the -scenarios are the questions worth asking of a loop: +process — and its live config model is read. That is what the command does. Driving the flow +against the worlds worth asking of a loop is the same machinery as a library, and is a call of +your own: ```python from hmz.flows import NEVER_DONE, SILENT, proved diff --git a/docs/guide/writing-a-flow.md b/docs/guide/writing-a-flow.md index 3eb37b6..aa42f6f 100644 --- a/docs/guide/writing-a-flow.md +++ b/docs/guide/writing-a-flow.md @@ -177,8 +177,9 @@ by taking its name. A file whose name starts with `_` is not a flow. ## Check your work Check the flow itself, before anything runs it: a static reading that executes nothing, then -the flow driven by stubs against a clock — including the world where the reviewer never says -the work is done. See [Checking a flow](/guide/checking-flows). +the flow loaded in a subprocess held to a clock. Driving it with stubs — including the world +where the reviewer never says the work is done — is `proved()`, a call of your own. See +[Checking a flow](/guide/checking-flows). ```sh hmz check local/twice diff --git a/src/hmz/cli/check.py b/src/hmz/cli/check.py index 62ff6e0..be36b49 100644 --- a/src/hmz/cli/check.py +++ b/src/hmz/cli/check.py @@ -147,17 +147,9 @@ def _said(found: list[Finding], *, as_json: bool, flows: int) -> None: import json for one in found: - print( - json.dumps( - { - "code": one.code, - "severity": one.severity, - "where": str(one.where), - "line": one.line, - "said": one.said, - } - ) - ) + # Off the tuple itself rather than field by field, so a field added to a + # finding later is a field a script reading this is handed. + print(json.dumps(one._asdict() | {"where": str(one.where)})) return from . import many diff --git a/src/hmz/flows/__init__.py b/src/hmz/flows/__init__.py index 845ea60..ef84811 100644 --- a/src/hmz/flows/__init__.py +++ b/src/hmz/flows/__init__.py @@ -68,7 +68,6 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: sub, told, ) -from .checking import Capability, Finding, briefed, catalogue, checked from .driving import ( NotAFlow, Place, @@ -82,8 +81,6 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: running, wanted, ) -from .prophesying import Prophesied, is_atlas, prophesied -from .proving import ALWAYS_DONE, NEVER_DONE, SILENT, Outcome, Proof, Scenario, proved from .verses import ( BUILTIN, FLOWS, @@ -132,6 +129,18 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: ) from hmz.backends import Model, Profile + from .checking import Capability, Finding, briefed, catalogue, checked + from .prophesying import Prophesied, is_atlas, prophesied + from .proving import ( + ALWAYS_DONE, + NEVER_DONE, + SILENT, + Outcome, + Proof, + Scenario, + proved, + ) + __all__ = [ "ALWAYS_DONE", "BUILTIN", @@ -248,8 +257,25 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: _MODULES = ("backends", "models") #: And the names a flow imports from here that are written down elsewhere: the vocabulary a -#: turn is described in, and where humanize keeps what outlives a run. +#: turn is described in, where humanize keeps what outlives a run, and the two readings of a +#: flow -- which are thousands of lines of `ast` apiece and are asked for by the one command +#: that checks a flow rather than by anything that lists, finds or runs one. _ELSEWHERE = { + "ALWAYS_DONE": "hmz.flows.proving", + "Capability": "hmz.flows.checking", + "Finding": "hmz.flows.checking", + "NEVER_DONE": "hmz.flows.proving", + "Outcome": "hmz.flows.proving", + "Proof": "hmz.flows.proving", + "Prophesied": "hmz.flows.prophesying", + "SILENT": "hmz.flows.proving", + "Scenario": "hmz.flows.proving", + "briefed": "hmz.flows.checking", + "catalogue": "hmz.flows.checking", + "checked": "hmz.flows.checking", + "is_atlas": "hmz.flows.prophesying", + "prophesied": "hmz.flows.prophesying", + "proved": "hmz.flows.proving", "AgentConfig": "hmz.agents", "Board": "hmz.agents", "AgentDefaults": "hmz.agents", diff --git a/src/hmz/flows/atlas.py b/src/hmz/flows/atlas.py index 491eda1..3df697e 100644 --- a/src/hmz/flows/atlas.py +++ b/src/hmz/flows/atlas.py @@ -225,7 +225,7 @@ def mind[**P, T]( @mind def write(agent: Agent, task: str) -> Draft: - return agent(f"draft this: {task}", shape=Draft) + return agent(f"draft this: {task}", schema=Draft) It has exactly one way out. What a model said is not a decision until something read it, so a branch is hung off a logic node and never off this: a prophecy that branched on a @@ -506,6 +506,8 @@ class Prophecy(NamedTuple): takes: The shape the flow is called with, which is `str` for one a command line runs and a model for one that is only ever a supernode. gives: The shape it answers with, and "" for one that answers with nothing. + config: The shape it is set up with, and "" for one that takes no setting up -- which + every supernode is, what is set up being the run rather than a node of it. agents: What the atlas calls each of the agents it drives, in the order it takes them. nodes: Every node, by node id. edges: Every way from one node to another, the way in and the way out included. @@ -631,27 +633,57 @@ def kept(prophecy: Prophecy) -> bytes: return pickle.dumps(prophecy, protocol=_PROTOCOL) +#: The only classes a shipped prophecy is allowed to name. A pickle says which class to +#: build as it goes, and the reader that took it at its word would run whatever the file +#: asked for -- which the static reading of a flow, whose whole promise is that it executes +#: nothing, must not do for a file it found in a directory it was pointed at. +_SHAPES = frozenset({"Edge", "Field", "Node", "Prophecy", "Reads", "Shape", "When"}) + + def told(said: bytes) -> Prophecy | None: """One shipped prophecy read back, or None where those bytes are not one. Note: - Reading this runs what the bytes say, which is what reading a pickle is. That is the - trust a flowverse already has: a flow is a directory of Python and reading one means - running it, so a repository shipping a prophecy is a repository already trusted with - this machine. What this adds is the check that what came back is a prophecy, so that - a file which is merely corrupt is refused rather than walked. + Nothing but a prophecy is built. A pickle names the class to build at every step, so + one read as it comes runs whatever the file names -- and this file is read by the + static reading of a flow, which is pointed at code nobody has read and promises to + execute none of it. So the classes are held to this module's own tuples, and bytes + naming anything else are bytes that are not a prophecy. Args: said: The bytes. Returns: The prophecy, or None for bytes that are not one -- truncated, written by something - else, or written by a humanize whose prophecies had another shape. + else, written by a humanize whose prophecies had another shape, or naming a class no + prophecy is made of. """ + import io import pickle + import sys as running + + class _Only(pickle.Unpickler): + """An unpickler that builds this module's own tuples and refuses everything else.""" + + def find_class(self, module: str, name: str) -> Any: + """Refuses every class a prophecy is not made of. + + Args: + module: The module the bytes name. + name: The class in it they name. + + Returns: + The class, for the tuples a prophecy is made of. + + Raises: + UnpicklingError: For anything else, which is what makes reading this safe. + """ + if module == __name__ and name in _SHAPES: + return getattr(running.modules[__name__], name) + raise pickle.UnpicklingError(f"a prophecy is not made of {module}.{name}") try: - held = pickle.loads(said) # noqa: S301 -- the note above: a flowverse is trusted + held = _Only(io.BytesIO(said)).load() except Exception: # noqa: BLE001 -- anything a pickle raises is a file that is not one return None if not isinstance(held, Prophecy): diff --git a/src/hmz/flows/checking.py b/src/hmz/flows/checking.py index 0fc7ed0..30b46ab 100644 --- a/src/hmz/flows/checking.py +++ b/src/hmz/flows/checking.py @@ -460,7 +460,10 @@ def _collected(read: _Read, body: list[ast.stmt], *, type_checking: bool) -> Non _named_sub(read, targets, node.value) elif isinstance(node, ast.ClassDef) and not type_checking: read.bound.add(node.name) - bases = {_root(base) for base in node.bases} + # By what each base is called at the tip: `pydantic.BaseModel` and + # `typing.NamedTuple` are the same two classes reached the other way, and + # one read at the root would be the module's name and neither of them. + bases = {_tip(base) for base in node.bases} if "NamedTuple" in bases: read.crews[node.name] = node elif "BaseModel" in bases or bases & set(read.models): @@ -807,6 +810,13 @@ def _kept(mark: _Mark, read: _Read, state: str) -> Iterator[Finding]: elif isinstance(node, ast.Subscript) and isinstance(node.ctx, ast.Store): if _root(node.value) in held: wrote = wrote or node.lineno + elif isinstance(node, ast.Delete): + # `del state[what]` is the same thing said the other way round: a flow that + # emptied what it kept is a flow the next run here opens on nothing. + cleared = cleared or any( + isinstance(one, ast.Subscript) and _root(one.value) in held + for one in node.targets + ) elif ( isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) @@ -814,7 +824,7 @@ def _kept(mark: _Mark, read: _Read, state: str) -> Iterator[Finding]: ): if node.func.attr in {"update", "setdefault"}: wrote = wrote or node.lineno - elif node.func.attr == "clear": + elif node.func.attr in {"clear", "pop", "popitem"}: cleared = True if wrote and not cleared: yield Finding( @@ -1560,15 +1570,21 @@ def _guards(test: ast.expr, scope: _Scope) -> None: either way round. Noted wherever it appears rather than matched to a branch: what the warning is for is the answer nobody tested at all. """ + # Through the walrus, which is how the answer and the guard over it are written on one + # line -- `if (said := agent(..., schema=X)) is not None:` -- and reading it as work + # rather than as a name would be a warning about a flow that guarded exactly right. + test = _named(test) if isinstance(test, ast.Name): scope.guarded.add(test.id) - elif isinstance(test, ast.UnaryOp) and isinstance(test.operand, ast.Name): - scope.guarded.add(test.operand.id) + elif isinstance(test, ast.UnaryOp): + held = _named(test.operand) + if isinstance(held, ast.Name): + scope.guarded.add(held.id) elif isinstance(test, ast.BoolOp): for one in test.values: _guards(one, scope) elif isinstance(test, ast.Compare): - sides = [test.left, *test.comparators] + sides = [_named(one) for one in (test.left, *test.comparators)] against_none = any( isinstance(side, ast.Constant) and side.value is None for side in sides ) @@ -1578,6 +1594,19 @@ def _guards(test: ast.expr, scope: _Scope) -> None: scope.guarded.add(side.id) +def _named(node: ast.expr) -> ast.expr: + """One test with the walrus around it taken off, which is the name it binds. + + Args: + node: The test, or one side of it. + + Returns: + The name a `:=` binds, and the node itself where there is no `:=` -- so that an answer + bound and tested on one line reads as the name it was bound to. + """ + return node.target if isinstance(node, ast.NamedExpr) else node + + def _asked(node: ast.Attribute, scope: _Scope) -> None: """One attribute asked of a name, checked where the name is tracked.""" if not isinstance(node.value, ast.Name) or node.attr.startswith("_"): diff --git a/src/hmz/flows/prophesying.py b/src/hmz/flows/prophesying.py index 08b343c..d4e0ac9 100644 --- a/src/hmz/flows/prophesying.py +++ b/src/hmz/flows/prophesying.py @@ -150,12 +150,19 @@ def prophesied( ), None, ) - found = list(_rules(whole)) - for read in whole.read: - found.extend(_dynamic(read)) + rules = _rules(whole) mark = next( (one for one in whole.entered.marks if one.atlas and one.name == name), None ) + if mark is None and any(one.name == name for one in whole.entered.marks): + # A flow of this file's, and an ordinary one: a file holds several flows and the + # reading each gets is its own. So the one asked for gets the reading that reads a + # body as a program, which is the reading it would have got had the file beside it + # held no atlas at all. + return Prophesied(rules, None) + found = list(rules) + for read in whole.read: + found.extend(_dynamic(read)) if mark is None: held = sorted(one.name for one in whole.entered.marks if one.atlas) found.append( @@ -172,10 +179,14 @@ def prophesied( whole, _gathered(whole), mark, name or _stem(whole), through ) found.extend(said) - if prophecy is not None: - found.extend(_shipped(whole, prophecy)) if any(one.severity == "error" for one in found): return Prophesied(tuple(found), None) + # After the gate rather than before it: what a flow ships is a fact about the file + # beside the source and not about the source, so saying the two have drifted apart must + # not take away the graph the source compiled to -- shipping that graph again is the + # one thing that answers the finding, and it is the one thing this would refuse. + if prophecy is not None: + found.extend(_shipped(whole, prophecy)) return Prophesied(tuple(found), prophecy) @@ -504,7 +515,11 @@ def _agents( Returns: One name per agent, in the order the flow takes them. """ - crew = held.crews.get(_root(param.annotation) if param.annotation else "") + # Read through its quoting, as every other annotation here is: a crew declared + # below the atlas that drives it is named in a string, and a name in a string is + # still the name it is. + written = _unquoted(param.annotation) + crew = held.crews.get(_root(written) if written is not None else "") if crew is None: found.append( _said( @@ -708,7 +723,10 @@ def _return(self, node: ast.Return, loose: _Loose) -> None: "thing a logic node reads", ) return - answers = node.value.id + # Through the same table a read goes through: what the entry point calls its + # own arguments is not what the run holds them under, so an atlas that answers + # with what it was called with names `@input` here as every other read does. + answers = self.names.get(node.value.id, node.value.id) given = self._shape_of(Reads(answers)) or NOTHING if given != (self.gives or NOTHING): self.found.append( @@ -739,7 +757,19 @@ def _branch(self, node: ast.If, loose: _Loose) -> _Loose: said, truth = read taken: _Loose = [(out_of, When(*said, truth)) for out_of, _ in loose] otherwise: _Loose = [(out_of, When(*said, not truth)) for out_of, _ in loose] - return [*self._block(node.body, taken), *self._block(node.orelse, otherwise)] + # One arm at a time, each starting from what was bound above it: a name one arm + # binds is a name the other path arrives without, and a node below the branch that + # read it would be handed nothing on that path. + was = dict(self.bound) + held = self._block(node.body, taken) + then, self.bound = self.bound, dict(was) + other = self._block(node.orelse, otherwise) + # And below the branch, only what both arms bound and bound the same: everything + # else is a name that holds something on one way here and nothing on the other. + self.bound = { + name: shape for name, shape in then.items() if self.bound.get(name) == shape + } + return [*held, *other] def _loop(self, node: ast.While, loose: _Loose) -> _Loose: """A `while`, which is a branch with an edge back to the node it reads. @@ -772,6 +802,18 @@ def _loop(self, node: ast.While, loose: _Loose) -> _Loose: return loose head = loose[0][0] said, truth = read + # And the node above it is the one it reads again: the loop's edge goes back there, + # so a `while` whose guard that node does not answer is a guard no round can change + # -- a loop with no way out, compiled from a body that reads as though it had one. + above = self._above(head) + if above is None or above.binds != said.reads: + self._refuse( + node, + f"a loop reads again the node above it, and this reads {_names(said)}, " + f"which {head or 'nothing yet'} does not answer -- put the node that " + "answers it directly above the loop, so each round asks it again", + ) + return loose opens: _Loose = [(head, When(*said, truth))] for out_of, when in self._block(node.body, opens): self.edges.append(Edge(out_of, head, when)) @@ -816,7 +858,12 @@ def _branched(self, test: ast.expr, loose: _Loose) -> tuple[Reads, bool] | None: What the branch reads, and whether the first way out is the one taken when that reads as true. None where the branch is refused. """ - said = test.operand if isinstance(test, ast.UnaryOp) else test + # `not` and nothing else: every other unary operator is work -- `~x` is falsy where + # `x` is truthy -- and one read as though it were the name under it would be a graph + # that branches the other way from the body it was compiled from. + said: ast.expr = ( + test.operand if isinstance(test, ast.UnaryOp) and _is_not(test) else test + ) truth = not _is_not(test) read = self._reads(said) if read is None: @@ -826,7 +873,8 @@ def _branched(self, test: ast.expr, loose: _Loose) -> tuple[Reads, bool] | None: " is work, and work is what a logic node is for", ) return None - if self._shape_of(read) is None: + shape = self._shape_of(read) + if shape is None: self.found.append( _said( "unbound-read", @@ -836,6 +884,18 @@ def _branched(self, test: ast.expr, loose: _Loose) -> tuple[Reads, bool] | None: ) ) return None + if not shape: + self.found.append( + _said( + "shape-mismatch", + self.where, + test.lineno, + f"{_names(read)} holds nothing -- the node that bound it answers with " + "nothing at all, so a branch reading it is one way out taken every " + "round and one taken never", + ) + ) + return None for out_of, when in loose: if when is not None: self._refuse( @@ -902,6 +962,17 @@ def _call(self, call: ast.Call, binds: str, loose: _Loose) -> _Loose: "nothing unpacked, so that what flows along each edge is one thing", ) return loose + if binds in self.names: + # What the entry point calls its own arguments is what the run holds the task, + # the agents and the config under, and a body binding one of those names would + # be a node whose answer nothing below it could read: every read of that name + # goes on answering with what the atlas was called with. + self._refuse( + call, + f"{binds} is what this atlas was called with -- bind the answer to a name " + "of its own, so that what reads it reads the node and not the run", + ) + return loose declared = self._declared(call, called) if declared is None: return loose @@ -1483,7 +1554,12 @@ def _required(value: ast.expr | None, held: _Held) -> bool: """ if value is None: return True - if isinstance(value, ast.Call) and _root(value.func) in held.fields: + # By what it is called at the tip: `Field(...)` is what a flow writes, and + # `pydantic.Field(...)` is the same call reached the other way -- one read at the + # root would be the module's name and would read every field as one with a default. + if isinstance(value, ast.Call) and ( + _root(value.func) in held.fields or _tip(value.func) == "Field" + ): named = {one.arg for one in value.keywords} return not (value.args or named & {"default", "default_factory"}) return False diff --git a/src/hmz/flows/proving.py b/src/hmz/flows/proving.py index e297a09..552c4fb 100644 --- a/src/hmz/flows/proving.py +++ b/src/hmz/flows/proving.py @@ -293,6 +293,20 @@ def _rested(seconds: float) -> None: del seconds +async def _rested_for(seconds: float, result: Any = None) -> Any: + """The same for a flow that rests the async way, which `asyncio.sleep` is. + + Args: + seconds: How long the flow meant to wait, which the proof does not. + result: What `asyncio.sleep` answers with, which it hands back untouched. + + Returns: + That same thing, at once. + """ + del seconds + return result + + class _Enough(BaseException): """The turn cap, raised past everything a flow catches: a proof is over when it is. @@ -327,6 +341,7 @@ def _driven(flow: str, spec: dict[str, Any]) -> dict[str, Any]: What the parent folds into the proof: `refused` for a flow that would not load, `findings` off the live config model, and how driving it went. """ + import asyncio import time from .driving import NotAFlow, declares, set_up @@ -336,6 +351,9 @@ def _driven(flow: str, spec: dict[str, Any]) -> dict[str, Any]: # five seconds a round is not five hundred seconds more legal than one that does not. # Patched before the flow is even loaded, so a `from time import sleep` reads this one. time.sleep = _rested + # And the other spelling of it: an async flow rests with `asyncio.sleep`, and one left + # sleeping would be reported as a flow that cannot end when it was only resting. + asyncio.sleep = _rested_for named = f"{flow}:{spec['name']}" if spec["name"] else flow scenario = Scenario(**spec["scenario"]) if spec["scenario"] else None diff --git a/src/hmz/flows/stepping.py b/src/hmz/flows/stepping.py index b4984c6..453c509 100644 --- a/src/hmz/flows/stepping.py +++ b/src/hmz/flows/stepping.py @@ -28,7 +28,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, cast -from .atlas import AGENTS, CONFIG, INPUT, Node, Reads, digest, shipped +from .atlas import AGENTS, ATLAS, CONFIG, INPUT, Node, Reads, digest, shipped if TYPE_CHECKING: import os @@ -48,6 +48,11 @@ _AT = "at" _DONE = "done" +#: And that the run reached the way out. What a run has done is kept for whoever reads it +#: back, so a finished one cannot be told from a stopped one by what it holds -- and the next +#: run of the flow, handed that, would walk every answer it already had and do no work at all. +_OVER = "over" + #: What one visit to a node is written down under: the node, and how many times the run has #: been through it -- a loop is one node visited again, and a round whose answer overwrote #: the last round's would be a run that could not be picked up inside a loop. @@ -217,10 +222,12 @@ def _stepped( Whatever the prophecy answers with, and None for one that answers with nothing. """ written = digest(prophecy) - if state.get(_PROPHECY) != written: + if state.get(_PROPHECY) != written or state.get(_OVER): # A different prophecy: the atlas was rewritten between the two runs, so what the # last one did, it did somewhere else. Cleared rather than merged, since a node - # that kept its name is not thereby the node it was. + # that kept its name is not thereby the node it was. And the same for a run that + # reached the way out: it is a run to read back rather than one to pick up, and + # picking it up would be a run with an answer for every node and nothing to do. state.clear() state[_PROPHECY] = written walk = _Walk( @@ -232,7 +239,12 @@ def _stepped( under="", beside={}, ) - return _walked(walk, given, config) + answered = _walked(walk, given, config) + # Written down as finished rather than emptied: what a run did is what whoever reads it + # back is after, and the next run of the flow is what must not be handed it. + state[_OVER] = True + _saved(state) + return answered def _walked(walk: _Walk, given: Any, config: BaseModel | None) -> Any: @@ -312,8 +324,11 @@ def _answered(walk: _Walk, bound: dict[str, Any], node: Node, held: str) -> Any: return None # Written down before the node runs and saved once: `State` saves itself as it is # written into, and what goes into `kept` below is a change inside a value it holds and - # cannot see -- which is the one that has to ask. - walk.state[_AT] = held + # cannot see -- which is the one that has to ask. Where a run stopped is the node that + # was running, so a supernode writes nothing here: the nodes under it write themselves, + # and one of theirs overwritten by this would be a run picked up past what it stopped in. + if node.kind != "atlas": + walk.state[_AT] = held answered = _ran(walk, bound, node, held) walk.kept[held] = _written(answered) _saved(walk.state) @@ -376,10 +391,13 @@ def _supernode(walk: _Walk, node: Node, held: str, said: list[Any]) -> Any: ) # Beside it or elsewhere: a supernode of this flow's own is in the file this prophecy # was compiled from, and one reached by name is a flow of its own, run to be read as any - # flow is. Read once for the run rather than once a visit: the graph was settled before - # the run started, and a file re-read between two rounds of a loop would be new code - # running under a shape that had already been agreed. - if node.calls in walk.inside: + # flow is. Told apart by the mark rather than by the name being one this file holds: + # `inner = sub("inner")` binds that name here too, and a membership test would read a + # flow of its own as one beside it and walk its nodes against the wrong file. Read once + # for the run rather than once a visit: the graph was settled before the run started, + # and a file re-read between two rounds of a loop would be new code running under a + # shape that had already been agreed. + if getattr(walk.inside.get(node.calls), ATLAS, None) is not None: beside = walk.inside elif node.calls not in walk.beside: walk.beside[node.calls] = beside = loaded(find(node.calls)) diff --git a/src/hmz/sdk/flows.py b/src/hmz/sdk/flows.py index 2228be5..3eb6e00 100644 --- a/src/hmz/sdk/flows.py +++ b/src/hmz/sdk/flows.py @@ -246,9 +246,12 @@ def check( ) if static or any(one.severity == "error" for one in found): return tuple(found) + # By what each said and not by its code alone: the two readings make the same + # findings about different fields, and one dropped for sharing a code with another + # is a field nothing ever mentions. proof = proved(whole, name=inside(str(named)), scenarios=()) - said = {one.code for one in found} - found.extend(one for one in proof.findings if one.code not in said) + said = {(one.code, one.said) for one in found} + found.extend(one for one in proof.findings if (one.code, one.said) not in said) return tuple(found) def prophecy(self, named: str | os.PathLike[str]) -> Prophecy | None: diff --git a/tests/test_prophesying.py b/tests/test_prophesying.py index 1d12375..b6bb722 100644 --- a/tests/test_prophesying.py +++ b/tests/test_prophesying.py @@ -57,7 +57,7 @@ class Verdict(BaseModel): @mind def write(agent: Agent, task: str) -> Draft: """One turn of writing.""" - return agent(task, shape=Draft) + return agent(task, schema=Draft) @logic From 877c4942ef65974e7ba6d7e5234b0c3644a45ab1 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 29 Aug 2026 09:16:29 +0000 Subject: [PATCH 17/18] fix(flows): settle the three the review left for a decision An `async def` node compiled clean and bound a bare coroutine as its answer, which the next node was then handed. The walk does not await, and what waits for a model is a turn -- which a mind already is -- so a node that is a coroutine is refused where it is called, and the message the atlas itself is refused with no longer promises otherwise. A run nobody set up was handed None where its config goes, and every read off it came back None: the body of an atlas cannot write `config or Config()`, `config` being a name and that being work. So such a run is handed the model's own defaults, and a config that cannot be built out of them is refused where it is declared -- `unset-config`, since a run may always be started with nothing. And the compiling, which the cleanup pass moved off the readers, went too far the other way: `load` no longer refused a body that will not compile where it was named, and `Runner` carried the refusal into `run`, where a container had been pulled and a cycle opened and the CLI reads a raise as a crash of its own. `readies` is the two places that are about to run one asking for it, so a flow picker still pays nothing and a bad atlas is still refused before anything is chosen. Co-Authored-By: Claude Opus 5 (1M context) --- docs/guide/atlas.md | 3 +- docs/reference/flows.md | 8 +++ src/hmz/flows/SPEC.md | 27 ++++++--- src/hmz/flows/driving.py | 42 +++++++++++++- src/hmz/flows/prophesying.py | 53 +++++++++++++++++- src/hmz/flows/stepping.py | 34 ++++++++++- src/hmz/runner.py | 6 +- tests/test_prophesying.py | 34 +++++++++++ tests/test_stepping.py | 106 ++++++++++++++++++++++++++++++++++- 9 files changed, 296 insertions(+), 17 deletions(-) diff --git a/docs/guide/atlas.md b/docs/guide/atlas.md index 6487178..ef9aa1e 100644 --- a/docs/guide/atlas.md +++ b/docs/guide/atlas.md @@ -127,7 +127,8 @@ if verdict.done and enough: # a compound test is a decision a logic node makes The rest of the file is ordinary Python. Only the `@atlas` body is narrowed — a mind or a logic may do whatever a Python function may do, and is read by the same rules as any other -flow's code. +flow's code. One exception: a node may not be `async def`, since the walk over the graph does +not await. What waits for a model is a turn, and a `mind` already is one. ## What flows between nodes diff --git a/docs/reference/flows.md b/docs/reference/flows.md index 6670230..182c125 100644 --- a/docs/reference/flows.md +++ b/docs/reference/flows.md @@ -1233,6 +1233,9 @@ from hmz.flows import Agent, atlas, canonical, digest, logic, mind, prophesied, `@mind` and `@logic` take `rerun=False` for a node a run picked up inside steps past rather than runs again; such a node answers with nothing. +Neither an atlas nor a node may be `async def`: the walk does not await, and what waits for +a model is a turn, which is what a `mind` already is. + An atlas's entry point takes its agents as a `NamedTuple` of them, then the one thing it is called with — `str` for one a command line runs, a model for one that is only ever a supernode — and then, for one that says it can be set up, a config: @@ -1242,6 +1245,10 @@ supernode — and then, for one that says it can be set up, a config: def run(agents: Agents, task: str, config: Config | None = None) -> None: ... ``` +Every field of that config needs a default: a run may be started without one, an atlas's body +has no way to write `config or Config()`, so a run nobody set up is handed the model's own +defaults. + ### The body One node per statement; the branches between them are the edges. Nothing else: @@ -1302,6 +1309,7 @@ do not block. | `skipped-answer` | `rerun=False` on a node that answers with something. | | `circular-atlas` | A supernode reaching back into a graph already being compiled. | | `dynamic-call` | An atlas importing `load`, which answers with a flow that may be anything. | +| `unset-config` | A config with a field that has no default, which a run nobody set up cannot be handed. | | `stale-prophecy` | A shipped `prophecy.pkl` that is not what the source now compiles to. | ### Shipping one diff --git a/src/hmz/flows/SPEC.md b/src/hmz/flows/SPEC.md index 32a8d12..9f85762 100644 --- a/src/hmz/flows/SPEC.md +++ b/src/hmz/flows/SPEC.md @@ -787,8 +787,16 @@ turns what it declared into the prophecy a run walks. - A body MUST hold only: one call per statement, bound to at most one name; an `if` and a `while` whose test reads a bound name or one field of it; a `return`; `pass`; and the docstring. Everything else -- arithmetic, a call inside a call, a comprehension, `try`, - `with`, `import`, `async` -- MUST be refused, each of them being a thing a node does and a - node being where it goes. + `with`, `import` -- MUST be refused, each of them being a thing a node does and a node + being where it goes. +- A node MUST NOT be a coroutine, and neither MUST an atlas. The walk over a prophecy does + not await, so a node written `async def` would answer with a coroutine and hand the next + node something no model is built from. What waits is a turn, and a turn is what a mind + already is. +- An atlas that says it can be set up MUST be able to be set up with nothing, every field of + its config having a default. A run may be started without one, and the body of an atlas + has no way to say what to do about that -- `config or Config()` is work, and work is what + a node is for -- so a run nobody set up MUST be handed the model's own defaults. - What flows along every edge MUST be checked before anything runs. A node's parameters and its answer MUST each name a shape, which MUST be a pydantic model the flow's own files declare or one of the plain kinds; and what arrives MUST be that shape, or a model holding @@ -828,13 +836,14 @@ Running a prophecy: one node at a time, and picking a stopped run up where it le - An atlas's body MUST NOT be run. It is a declaration, and what runs MUST be the prophecy compiling it made -- which is what puts a run in a position to be stopped and started at all. -- The compiling MUST happen before the first node runs, and MUST NOT happen where a flow is - only read. What a flow drives, what it can be set up with and whether it can be picked up - are questions its entry point's own annotation answers, and an atlas that had to be - compiled to be asked one of them would be an atlas a flow picker could not list -- and one - that does not compile would answer no rather than say why. A body that does not compile - MUST be refused where it is run, saying every reason at once, and every way of running a - flow MUST get both the compiling and the walking without knowing there are two kinds. +- The compiling MUST happen where a run of a flow is being set up, and MUST NOT happen where + a flow is only read. What a flow drives, what it can be set up with and whether it can be + picked up are questions its entry point's own annotation answers, and an atlas that had to + be compiled to be asked one of them would be an atlas a flow picker could not list -- and + one that does not compile would answer no rather than say why. A body that does not compile + MUST be refused before the run has chosen anything, pulled anything or opened anything, + saying every reason at once; and every way of running a flow MUST get both the compiling + and the walking without knowing there are two kinds. - What a run has done MUST be the answers it has, written down as each arrives rather than when the run ends: a run worth picking up is one that was stopped or killed rather than one that ended tidily. Each MUST be written down against the node and the visit, since a loop is diff --git a/src/hmz/flows/driving.py b/src/hmz/flows/driving.py index 4533aeb..05b6482 100644 --- a/src/hmz/flows/driving.py +++ b/src/hmz/flows/driving.py @@ -68,6 +68,7 @@ "lands_in", "left", "load", + "readies", "resumes", "running", "set_up", @@ -617,6 +618,17 @@ def __call__(self, *said: Any) -> Awaitable[None] | None: Returns: Whatever the prophecy answers with. + Raises: + NotAFlow: If the atlas does not compile, saying each reason on a line of its own. + """ + return self.ready()(*said) + + def ready(self) -> Entry: + """Compiles the atlas, if this is the first thing to ask for it. + + Returns: + The walk over the prophecy. + Raises: NotAFlow: If the atlas does not compile, saying each reason on a line of its own. """ @@ -624,7 +636,31 @@ def __call__(self, *said: Any) -> Awaitable[None] | None: from .stepping import walking self._walk = walking(self._named, self._read, self._entry) - return self._walk(*said) + return self._walk + + +def readies(run: Entry) -> Entry: + """Compiles whatever a flow has to have compiled before a run of it starts. + + An atlas is compiled when something reaches for the run rather than when a flow is read, + so that asking what a flow drives, what it can be set up with or whether it can be picked + up neither pays for a reading of every file it holds nor is refused by one. The two + places that are about to run one ask here instead: a body that does not compile is then + refused where the run is being set up, rather than from inside a run that has already + pulled an image and opened a cycle. + + Args: + run: What :func:`declares` answered with. + + Returns: + The same thing, ready to be called. + + Raises: + NotAFlow: If it is an atlas that does not compile. + """ + if isinstance(run, _Walked): + run.ready() + return run def _settles(agent: Agent) -> Driven: @@ -817,7 +853,9 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: asked for rather than an hour into a loop -- and again at each call, for a flow that was rewritten into something that is no longer one. """ - declares(flow) # said now, so a name that is wrong is wrong where it was written + # Said now, so a name that is wrong -- or an atlas whose body will not compile -- is + # wrong where it was written rather than an hour into a loop. + readies(declares(flow)[0]) named = str(flow) skill_policy = _INHERITED_SKILLS if inherit_skills else _ISOLATED_SKILLS diff --git a/src/hmz/flows/prophesying.py b/src/hmz/flows/prophesying.py index d4e0ac9..8c49161 100644 --- a/src/hmz/flows/prophesying.py +++ b/src/hmz/flows/prophesying.py @@ -405,8 +405,8 @@ def _compiled( "unstatic-body", where, node.lineno, - "an atlas is compiled rather than awaited -- what takes time is a node, " - "and a node is where an `async def` goes", + "an atlas is compiled rather than awaited: the body is read and the " + "graph is what runs, so there is nothing here to wait for", ) ) if not _TAKES <= len(params) <= _AND_A_CONFIG: @@ -430,6 +430,8 @@ def _compiled( if len(params) == _AND_A_CONFIG else "" ) + if config: + found.extend(_settled(config, held, where, params[2].lineno)) answers = "" if gives == NOTHING else gives wiring = _Wiring( whole=whole, @@ -497,6 +499,41 @@ def _kind( return "" +def _settled(config: str, held: _Held, where: Path, line: int) -> list[Finding]: + """Whether an atlas's config can be built by a run that was not set up. + + A run may be started with nothing, and the body of an atlas has no way to say what to do + about that: `config or Config()` is work, and work is what a node is for. So a run + nobody set up is handed the model's own defaults -- and a model that cannot be built out + of its defaults is one such a run has no config for at all. + + Args: + config: The model, by name. + held: What the flow's files declare. + where: The file, for a finding. + line: The line the config is declared on. + + Returns: + An `unset-config` error per field the model refuses to be built without. + """ + model = held.models.get(config) + if model is None: + return [] + short = [one.name for one in _fields(model, held) if one.required] + if not short: + return [] + return [ + _said( + "unset-config", + where, + line, + f"{config} requires {', '.join(short)}, and a run that was not set up has " + "nothing to give -- an atlas is handed its config's own defaults, so every " + "field of one has to have a default", + ) + ] + + def _agents( param: ast.arg, held: _Held, where: Path, found: list[Finding] ) -> tuple[str, ...]: @@ -1048,6 +1085,18 @@ def _noded(self, call: ast.Call, called: str, held: _Node) -> _Declared | None: Returns: Its declaration, or None where the function's shapes cannot be read. """ + if isinstance(held.node, ast.AsyncFunctionDef): + self.found.append( + _said( + "unstatic-body", + self.where, + call.lineno, + f"{called} is a coroutine, and the walk over a prophecy does not " + "await -- a node answers with a shape, and one answering with a " + "coroutine would hand the next node something no model is built from", + ) + ) + return None params = [*held.node.args.posonlyargs, *held.node.args.args] takes: _Takes = [] for at, one in enumerate(params): diff --git a/src/hmz/flows/stepping.py b/src/hmz/flows/stepping.py index 453c509..fa3c662 100644 --- a/src/hmz/flows/stepping.py +++ b/src/hmz/flows/stepping.py @@ -114,7 +114,9 @@ def running(agents: Any, task: Any, *said: Any) -> Any: # is always resumable: what a run of one has done is which of its nodes answered. state: dict[str, Any] = said[-1] if said else {} config = said[0] if len(said) > 1 else None - return _stepped(walked, inside, agents, task, config, state) + return _stepped( + walked, inside, agents, task, _set_up(walked, inside, config), state + ) # Whatever the entry point was marked with, and not the two marks known today: both # `flow` and `atlas` set theirs into the function's own `__dict__`, which is exactly @@ -122,6 +124,36 @@ def running(agents: Any, task: Any, *said: Any) -> Any: return functools.update_wrapper(running, entry, assigned=(), updated=("__dict__",)) +def _set_up( + prophecy: Prophecy, inside: Mapping[str, Any], config: BaseModel | None +) -> BaseModel | None: + """What a run of an atlas is set up with, which is its defaults where nobody said. + + An atlas's body has no way to make one: `config or Config()` is work, and work is what a + node is for. So the model's own defaults stand in, and the compiling refuses a config + that cannot be built out of them -- which is what makes this always answer with one. + + Args: + prophecy: The compiled atlas, for the model it says it can be set up with. + inside: What running its file left behind, which is where that model is. + config: What the run was set up with, or None for one nobody set up. + + Returns: + The config, and None for an atlas that takes no setting up. + """ + from pydantic import BaseModel + + if config is not None or not prophecy.config: + return config + model = inside.get(prophecy.config) + if not (isinstance(model, type) and issubclass(model, BaseModel)): + return None + try: + return model() + except Exception: # noqa: BLE001 -- a model the compiling said could be built and now + return None # cannot is a file rewritten under the run, not a run to end here + + def _shipped(under: Path, wanted: str) -> Prophecy | None: """The prophecy a flow's own directory ships, where it ships the one being asked for. diff --git a/src/hmz/runner.py b/src/hmz/runner.py index 4455681..c1cfe2a 100644 --- a/src/hmz/runner.py +++ b/src/hmz/runner.py @@ -113,9 +113,13 @@ def __init__( """ from .agents import HumanAgent from .cycle import resumed - from .flows.driving import NotAFlow, carries, declares, lands, set_up + from .flows.driving import NotAFlow, carries, declares, lands, readies, set_up run, places, make, setting, mark = declares(flow) + # Before anything is chosen or opened: an atlas whose body does not compile is a + # flow refused where the run is set up rather than from inside one that has already + # pulled an image and opened a cycle. + readies(run) if config is not None: config = set_up(flow, setting, config) asked = [place for place in places if not place.person] diff --git a/tests/test_prophesying.py b/tests/test_prophesying.py index b6bb722..a6e93dd 100644 --- a/tests/test_prophesying.py +++ b/tests/test_prophesying.py @@ -590,3 +590,37 @@ def run(agents: Agents, task: str) -> None: at = written(tmp_path, "one", HEAD + body) assert "sleeping-loop" in {one.code for one in checked(at)} + + +def test_a_node_the_walk_cannot_await_is_refused(tmp_path: Path) -> None: + """A coroutine bound as an answer is what the next node cannot be built from.""" + body = '''@mind +async def slowly(agent: Agent, task: str) -> Draft: + """A node that is a coroutine.""" + return Draft(text=task) + + +@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = slowly(agents.writer, task) +''' + assert "unstatic-body" in _codes(tmp_path, body) + + +def test_a_config_a_run_may_not_be_set_up_with_has_defaults(tmp_path: Path) -> None: + """A body cannot write `config or Config()`, so the model has to stand in for itself.""" + body = '''class Held(BaseModel): + """What it takes, and cannot be built without.""" + + model_config = {"extra": "forbid"} + + rounds: int = Field(description="how many rounds, with nothing to fall back on") + + +@atlas +def run(agents: Agents, task: str, config: Held | None = None) -> None: + """Says it.""" + draft = write(agents.writer, task) +''' + assert "unset-config" in _codes(tmp_path, body) diff --git a/tests/test_stepping.py b/tests/test_stepping.py index f501fb3..eec877c 100644 --- a/tests/test_stepping.py +++ b/tests/test_stepping.py @@ -14,7 +14,7 @@ from hmz.agents import AgentConfig from hmz.cycle import cycles, state -from hmz.flows import PROPHECY, NotAFlow, kept, resumes +from hmz.flows import PROPHECY, NotAFlow, configures, kept, resumes, wanted from hmz.flows.prophesying import prophesied from hmz.runner import Runner from hmz.sdk import Hmz @@ -453,3 +453,107 @@ def test_a_supernode_answers_with_what_its_return_named(project: Path) -> None: # Two turns were taken, and what came back out is the first one's answer. assert (project / "ran.txt").read_text() == "start deepen deepen " assert (project / "out.txt").read_text() == "hello!" + + +#: One that reads the config, to be run with and without being set up. +BOUNDED = '''"""Reads what the run was set up with, or the defaults where it was not.""" + +from pathlib import Path +from typing import NamedTuple + +from pydantic import BaseModel, Field + +from hmz.flows import Agent, atlas, logic, mind + + +class Agents(NamedTuple): + """Who it drives.""" + + writer: Agent + + +class Config(BaseModel): + """What it takes.""" + + model_config = {"extra": "forbid"} + + rounds: int = Field(default=2, description="how many rounds it may take") + + +class Said(BaseModel): + """What flows.""" + + model_config = {"extra": "forbid"} + + text: str = Field(description="the text") + + +@mind +def first(agent: Agent, task: str) -> Said: + """One turn.""" + agent.new()("true") + return Said(text=task) + + +@logic +def bounded(said: Said, rounds: int) -> None: + """Writes down the bound it was handed.""" + Path("rounds.txt").write_text(str(rounds)) + + +@atlas +def run(agents: Agents, task: str, config: Config | None = None) -> None: + """Says it.""" + said = first(agents.writer, task) + bounded(said, config.rounds) +''' + + +def test_a_run_nobody_set_up_is_handed_the_config_defaults(project: Path) -> None: + """The body has no way to make one, so the model stands in for itself.""" + _write(project, "bounded", BOUNDED) + + _run("bounded") + + assert (project / "rounds.txt").read_text() == "2" + + +def test_a_run_that_was_set_up_is_handed_what_it_was_set_up_with(project: Path) -> None: + """And the defaults are a fallback rather than a ceiling.""" + _write(project, "bounded", BOUNDED) + + Runner("bounded", [ShellAgent(CONFIG)], config={"rounds": 9}).run("go") + + assert (project / "rounds.txt").read_text() == "9" + + +def test_an_atlas_that_will_not_compile_is_refused_before_the_run_is_set_up( + project: Path, +) -> None: + """Rather than from inside one that has pulled an image and opened a cycle.""" + _write( + project, + "three", + THREE.replace("RERUN", "True").replace( + " last(held)", " for one in [1, 2]:\n last(held)" + ), + ) + + with pytest.raises(NotAFlow, match="does not compile"): + Runner("three", [ShellAgent(CONFIG)]) + + +def test_reading_a_flow_does_not_compile_it(project: Path) -> None: + """A picker asks three questions of every flow, and an atlas must answer all three.""" + _write( + project, + "three", + THREE.replace("RERUN", "True").replace( + " last(held)", " for one in [1, 2]:\n last(held)" + ), + ) + + # It does not compile, and every one of these is answered off the entry point alone. + assert resumes("three") is True + assert configures("three") is None + assert [one.name for one in wanted("three")] == ["writer"] From 3657638d5d4fb3740a3072a583ccb4734e7f5dd2 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Sat, 29 Aug 2026 13:36:24 +0000 Subject: [PATCH 18/18] fix(flows): the two things running an atlas for real turned up Both were found by driving `hmz exec` with live turns rather than by reading. Writing the loop's head again at the bottom of the body is the natural Python and the wrong graph: the edge back runs the head anyway, so the copy ran first and had its answer thrown away -- and a node with an effect had it twice a round with nothing said. Observed as one logic node printing its round twice for one turn. `twice-round` refuses it and says to take it out of the body. And one thing wrong in a body was four findings. `for one in [1, 2]:` drew the statement itself, two `unbound-read`s for names it would have bound, and "an atlas with no nodes in it" -- three consequences a reader has to work out are not separate mistakes. What a refused statement would have bound is now spoilt rather than unbound, that carries through a call which was itself refused, and the empty-body finding is only made where nothing else was. One mistake, one finding. Co-Authored-By: Claude Opus 5 (1M context) --- docs/guide/atlas.md | 10 ++++ docs/reference/flows.md | 1 + src/hmz/flows/SPEC.md | 7 +++ src/hmz/flows/prophesying.py | 98 +++++++++++++++++++++++++++++------- tests/test_prophesying.py | 34 +++++++++++++ 5 files changed, 132 insertions(+), 18 deletions(-) diff --git a/docs/guide/atlas.md b/docs/guide/atlas.md index ef9aa1e..ff92c7f 100644 --- a/docs/guide/atlas.md +++ b/docs/guide/atlas.md @@ -160,6 +160,16 @@ write → review → settled ──[done]──▶ (end) A loop whose body changes nothing the head reads would answer the same thing every round, and is refused as a `dead-loop` rather than run for a week. +Don't write the head again at the bottom of the body. It is the natural Python and the wrong +graph — the edge back runs the head anyway, so the copy would run first and have its answer +thrown away. `twice-round` refuses it: + +``` +…/__init__.py:71: error: twice-round: the body of this loop ends with settled, which is what +the loop reads again each round -- so it would run twice a round and the body's answer be +thrown away; take it out of the body +``` + ## Stopping and starting An atlas can always be picked up where the last run of it left off, and says so without being diff --git a/docs/reference/flows.md b/docs/reference/flows.md index 182c125..3b786ef 100644 --- a/docs/reference/flows.md +++ b/docs/reference/flows.md @@ -1310,6 +1310,7 @@ do not block. | `circular-atlas` | A supernode reaching back into a graph already being compiled. | | `dynamic-call` | An atlas importing `load`, which answers with a flow that may be anything. | | `unset-config` | A config with a field that has no default, which a run nobody set up cannot be handed. | +| `twice-round` | A loop body ending with the node the loop reads again, which would run it twice a round. | | `stale-prophecy` | A shipped `prophecy.pkl` that is not what the source now compiles to. | ### Shipping one diff --git a/src/hmz/flows/SPEC.md b/src/hmz/flows/SPEC.md index 9f85762..61c6067 100644 --- a/src/hmz/flows/SPEC.md +++ b/src/hmz/flows/SPEC.md @@ -806,6 +806,13 @@ turns what it declared into the prophecy a run walks. every turn in a prophecy names the agent it drives, and a place with no name is a turn nothing can be pointed at. A mind MUST be handed one of them and a supernode all of them, and neither MUST be handed anything else. +- A loop's body MUST NOT end with the node the loop reads again. The edge back runs the head, + so a body that repeats it runs it twice a round and throws the body's answer away -- and a + node with an effect would have it twice with nothing said. +- One thing wrong in a body MUST be one finding. What a refused statement would have bound + MUST be read as spoilt rather than as unbound, and a body with no nodes in it MUST be said + only where nothing else was: a reader given four findings for one mistake has three to work + out are consequences. - A branch MUST follow a node, and MUST NOT follow another branch: an `elif`, or an arm with nothing in it, is two decisions carried on one edge. A loop MUST leave exactly one node, which is its head -- what the test reads, answered again each round -- and the body MUST diff --git a/src/hmz/flows/prophesying.py b/src/hmz/flows/prophesying.py index 8c49161..c9856c0 100644 --- a/src/hmz/flows/prophesying.py +++ b/src/hmz/flows/prophesying.py @@ -674,6 +674,9 @@ def __init__( self.bound: dict[str, str] = {} #: How many nodes each callee has been so far, for the id the next one gets. self.seen: dict[str, int] = {} + #: The names a refused statement would have bound. Nothing is known about what they + #: hold, and reading one is not a second mistake: it is the first one, further down. + self.spoilt: set[str] = set() #: Every shape anything in this prophecy carries, for the shapes it is written with. self.carried: set[str] = {one for one in (takes, gives, config) if one} @@ -699,7 +702,9 @@ def walk(self, body: list[ast.stmt]) -> None: ) break self.edges.append(Edge(out_of, "", when)) - if not self.nodes: + # Only where nothing else was wrong: a body whose one statement was refused has no + # nodes *because* of that, and saying both is saying one thing twice. + if not self.nodes and not self.found: self.found.append( _said( "unstatic-body", @@ -729,7 +734,12 @@ def _block(self, body: list[ast.stmt], loose: _Loose) -> _Loose: self._refuse(one, "nothing here can run: the atlas ends above it") return [] if isinstance(one, ast.Assign) and isinstance(one.value, ast.Call): - loose = self._call(one.value, self._target(one), loose) + binds, held = self._target(one), len(self.nodes) + loose = self._call(one.value, binds, loose) + if binds and len(self.nodes) == held: + # The call was refused, so the name it would have bound holds nothing: + # reading it below is this same mistake again rather than another. + self.spoilt.add(binds) elif isinstance(one, ast.Expr) and isinstance(one.value, ast.Call): loose = self._call(one.value, "", loose) elif isinstance(one, ast.If): @@ -852,12 +862,45 @@ def _loop(self, node: ast.While, loose: _Loose) -> _Loose: ) return loose opens: _Loose = [(head, When(*said, truth))] - for out_of, when in self._block(node.body, opens): + inside = self._block(node.body, opens) + self._twice(node, head, inside) + for out_of, when in inside: self.edges.append(Edge(out_of, head, when)) self._endless(node, head) ends: _Loose = [(head, When(*said, not truth))] return ends + def _twice(self, node: ast.While, head: str, inside: _Loose) -> None: + """Whether a loop's body ends with the very node the loop reads again. + + Writing the head again at the bottom of the body is the natural Python and the wrong + graph: the edge back goes to the head, so the head answers again anyway. The body's + copy would run first, its answer would be thrown away, and a node with an effect -- + a line written, a message sent -- would have it twice a round with nothing said. + + Args: + node: The loop. + head: The node it reads again, by node id. + inside: The ends the body left open. + """ + above = self._above(head) + if above is None: + return + for out_of, _ in inside: + last = self._above(out_of) + if last is not None and last.calls == above.calls: + self.found.append( + _said( + "twice-round", + self.where, + node.lineno, + f"the body of this loop ends with {above.calls}, which is what the " + "loop reads again each round -- so it would run twice a round and " + "the body's answer be thrown away; take it out of the body", + ) + ) + return + def _endless(self, node: ast.While, head: str) -> None: """Whether one loop's head can ever answer differently, which is whether it ends. @@ -912,14 +955,15 @@ def _branched(self, test: ast.expr, loose: _Loose) -> tuple[Reads, bool] | None: return None shape = self._shape_of(read) if shape is None: - self.found.append( - _said( - "unbound-read", - self.where, - test.lineno, - f"nothing here has bound {_names(read)}", + if read.reads not in self.spoilt: + self.found.append( + _said( + "unbound-read", + self.where, + test.lineno, + f"nothing here has bound {_names(read)}", + ) ) - ) return None if not shape: self.found.append( @@ -1345,14 +1389,15 @@ def _fits( return self._agented(call, called, param, shape, read) given = self._shape_of(read) if given is None: - self.found.append( - _said( - "unbound-read", - self.where, - call.lineno, - f"nothing here has bound {_names(read)}", + if read.reads not in self.spoilt: + self.found.append( + _said( + "unbound-read", + self.where, + call.lineno, + f"nothing here has bound {_names(read)}", + ) ) - ) return False if not given or not _same(given, shape, self.held): self.found.append( @@ -1487,8 +1532,25 @@ def _id(self, called: str) -> str: return called if at == 1 else f"{called}:{at}" def _refuse(self, node: ast.stmt | ast.expr, said: str) -> None: - """Says one thing the body holds that the subset an atlas is written in does not.""" + """Says one thing the body holds that the subset an atlas is written in does not. + + Whatever the refused statement would have bound is remembered as spoilt, so that + reading it further down is not reported as a mistake of its own: one thing wrong in + a body is one finding, and a reader given four for it has three to work out are + consequences. + + Args: + node: The statement or expression being refused. + said: What is wrong with it. + """ self.found.append(_said("unstatic-body", self.where, node.lineno, said)) + self.spoilt.update( + one.id + for held in ast.walk(node) + if isinstance(held, ast.Assign) + for one in held.targets + if isinstance(one, ast.Name) + ) # --------------------------------------------------------------------------------------- diff --git a/tests/test_prophesying.py b/tests/test_prophesying.py index a6e93dd..dc6d0c9 100644 --- a/tests/test_prophesying.py +++ b/tests/test_prophesying.py @@ -624,3 +624,37 @@ def run(agents: Agents, task: str, config: Held | None = None) -> None: draft = write(agents.writer, task) ''' assert "unset-config" in _codes(tmp_path, body) + + +def test_a_loop_body_that_ends_with_the_node_it_reads_is_refused( + tmp_path: Path, +) -> None: + """The edge back runs the head again, so writing it twice is running it twice.""" + body = '''@atlas +def run(agents: Agents, task: str) -> None: + """Says it.""" + draft = write(agents.writer, task) + verdict = judge(draft) + while not verdict.done: + draft = write(agents.writer, task) + verdict = judge(draft) +''' + assert "twice-round" in _codes(tmp_path, body) + + +def test_one_thing_wrong_in_a_body_is_one_finding(tmp_path: Path) -> None: + """A name a refused statement would have bound is that mistake again, not another.""" + body = '''@atlas +def run(agents: Agents, task: str) -> None: + """A body the subset does not allow.""" + for one in [1, 2]: + draft = write(agents.writer, task) + verdict = judge(draft) + while not verdict.done: + draft = write(agents.writer, task) +''' + said = _codes(tmp_path, body) + + # The `for`, and nothing about the names it would have bound, nor about the body + # having no nodes in it -- both of which follow from it rather than stand beside it. + assert said == ["unstatic-body"]