diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 5e9d024..e411d0e 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -223,6 +223,8 @@ 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..ff92c7f --- /dev/null +++ b/docs/guide/atlas.md @@ -0,0 +1,291 @@ +# 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, schema=Draft) + + +@mind +def review(agent: Agent, draft: Draft) -> Verdict: + """One turn of reviewing.""" + return agent(f"review this:\n\n{draft.text}", schema=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 claude/sonnet:high -a 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. 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 + +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. + +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 +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 new file mode 100644 index 0000000..85e5009 --- /dev/null +++ b/docs/guide/checking-flows.md @@ -0,0 +1,94 @@ +# Checking a flow + +Check a flow before anything runs it: a static reading that executes nothing, then the flow +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. + +## 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 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. 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 + +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) + +## 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/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..aa42f6f 100644 --- a/docs/guide/writing-a-flow.md +++ b/docs/guide/writing-a-flow.md @@ -176,7 +176,16 @@ 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 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 +``` + +And check which agents the flow declares. ```python from hmz.flows import drives @@ -184,8 +193,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 4bdf62e..99bb041 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -277,6 +277,49 @@ 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] [--prophecy | --ship] 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. | +| `--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 +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. + +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` Where flows come from: a git repository with a `flows/` directory apiece, cloned under @@ -579,7 +622,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/flows.md b/docs/reference/flows.md index 8ecde32..3b786ef 100644 --- a/docs/reference/flows.md +++ b/docs/reference/flows.md @@ -1130,6 +1130,206 @@ 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`. | +| `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=...)`. | +| `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. + +## 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. + +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: + +```python +@atlas +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: + +| | | +| --- | --- | +| `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`, `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 +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. | +| `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 + +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 50cc205..6f13d2b 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -86,6 +86,9 @@ 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. | +| `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 26e9e91..592dcfc 100644 --- a/src/hmz/SPEC.md +++ b/src/hmz/SPEC.md @@ -670,6 +670,33 @@ 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] [--prophecy | --ship] [...] +``` + +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. +- 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` ```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..be36b49 --- /dev/null +++ b/src/hmz/cli/check.py @@ -0,0 +1,164 @@ +"""``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 + from hmz.sdk.flows import Flows + +__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", + ) + 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 + + from hmz.sdk import Hmz + + flows = Hmz().flows + 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(flows, 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) + warned = len(found) - errors + return 1 if errors or (args.strict and warned) else 0 + + +def _foretold(held: Flows, flows: list[str], *, ship: bool) -> int: + """Prints or writes what each atlas on the line compiles to. + + Args: + 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. + + 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 + + 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. + + 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: + # 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 + + 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/flows/SPEC.md b/src/hmz/flows/SPEC.md index f163a2d..61c6067 100644 --- a/src/hmz/flows/SPEC.md +++ b/src/hmz/flows/SPEC.md @@ -6,9 +6,14 @@ . ├── __init__.py ├── agent.py +├── atlas.py ├── builtin +├── checking.py ├── driving.py +├── prophesying.py +├── proving.py ├── skills.py +├── stepping.py └── verses.py ``` @@ -105,6 +110,12 @@ def inside(named_: str) -> str: ... def about(named_: str) -> str: ... +def reading(named_: str) -> str: ... + + +def foretold(named_: str) -> str: ... + + def __getattr__(name: str) -> object: ... ``` @@ -130,11 +141,22 @@ 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 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 @@ -164,8 +186,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 +451,428 @@ 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. + +## `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. + +## `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 + answers: str = "" + + +class Prophecy(NamedTuple): + name: str + takes: str + gives: str + config: 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: ... + + +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 +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. +- 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` + +```python +class Prophesied(NamedTuple): + findings: tuple[Finding, ...] + 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], + *, + 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. + +- 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. +- 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` -- 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 + 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 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 + 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 + 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 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 + 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. 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 + quietly running something else is the one thing shipping it was meant to rule out. + ## `verses.py` ```python diff --git a/src/hmz/flows/__init__.py b/src/hmz/flows/__init__.py index 1406452..ef84811 100644 --- a/src/hmz/flows/__init__.py +++ b/src/hmz/flows/__init__.py @@ -52,6 +52,22 @@ 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, + Shipped, + atlas, + canonical, + digest, + kept, + logic, + mind, + shipped, + sub, + told, +) from .driving import ( NotAFlow, Place, @@ -113,15 +129,32 @@ 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", "BUILTIN_AT", "ENTRY", "EVERYWHERE", "FLOWS", "LOCAL", + "MINE", + "NEVER_DONE", "OFFICIAL", "PERMISSIONS", + "PROPHECY", + "SILENT", "SWARM", "USER", "WINDOW", @@ -129,9 +162,12 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "AgentConfig", "AgentDefaults", "Board", + "Capability", "Driven", + "Edge", "Event", "Failed", + "Finding", "Flow", "Flowverse", "Goal", @@ -143,17 +179,25 @@ def run(agents: tuple[Agent, Agent], task: str) -> None: "Item", "Model", "Moment", + "Node", "NotAFlow", "Occasion", "Offer", + "Outcome", "Person", "Place", "Profile", + "Proof", + "Prophecy", + "Prophesied", "Question", "Refused", "Remote", "Running", + "Scenario", "Session", + "Shape", + "Shipped", "Stopped", "Tool", "Unhooked", @@ -162,29 +206,46 @@ 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", + "is_atlas", + "kept", "load", "loaded", + "logic", + "mind", "models", "nearest", "offered", "offers", + "prophesied", + "proved", + "reading", "resumes", "running", + "shipped", + "sub", + "told", "wanted", ] @@ -196,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", @@ -268,6 +346,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 = ":" @@ -744,6 +828,54 @@ 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. + + 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. + """ + from .atlas import shipped + + beside = at(named_) + held = shipped(beside) if beside else None + return "" if held is None else str(held.at) + + 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..3df697e --- /dev/null +++ b/src/hmz/flows/atlas.py @@ -0,0 +1,755 @@ +"""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 pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, overload + +if TYPE_CHECKING: + import os + from collections.abc import Callable, Iterable + +__all__ = [ + "AGENTS", + "CONFIG", + "INPUT", + "Atlas", + "Edge", + "Field", + "Marked", + "Node", + "Prophecy", + "Reads", + "Shape", + "Shipped", + "Sub", + "When", + "atlas", + "canonical", + "digest", + "kept", + "logic", + "mind", + "shipped", + "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}", 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 + 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. + """ + return _noded("mind", call, rerun=rerun) + + +@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. + """ + 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(kind, 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. + 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): + """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. + 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. + 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 + config: 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, 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 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 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) + ], + } + + +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.""" + 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 +#: 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) + + +#: 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: + 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, 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 = _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): + 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 + + +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. + + 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 new file mode 100644 index 0000000..30b46ab --- /dev/null +++ b/src/hmz/flows/checking.py @@ -0,0 +1,2209 @@ +"""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 +import contextlib +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, Mapping, Sequence + +__all__ = [ + "Capability", + "Finding", + "briefed", + "catalogue", + "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. + + 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. + + 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. + """ + 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. + 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 + read: list[_Read] + entered: _Read | None + found: list[Finding] + compiled: bool + declared: frozenset[int] + + +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) + 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 _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, + declared=frozenset(), + ) + + found: list[Finding] = [] + read: list[_Read] = [] + for one in files: + held = _parsed(one) + if isinstance(held, Finding): + found.append(held) + else: + 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, + declared=frozenset( + id(one.node) for said in read for one in said.marks if one.atlas + ), + ) + + +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. + + 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", + 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", + ) + ) + if entered is not None and ast.get_docstring(entered.tree) is None: + found.append( + Finding( + "unsaid-flow", + "warning", + 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", + ) + ) + + # 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 whole.read for moment in one.moments_declared + ) + asks = _Asks() + for one in whole.read: + found.extend(_imports(one)) + found.extend(_marks(one)) + found.extend(_hooks(one, declared)) + found.extend(_functions(one, asks, whole.declared)) + 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", +} + +#: 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 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 +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]) + #: 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. + 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]) + #: 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: + """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 == "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: + 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)) + _named_sub(read, targets, node.value) + elif isinstance(node, ast.ClassDef) and not type_checking: + read.bound.add(node.name) + # 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): + 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) + 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) + _collected(read, node.orelse, type_checking=type_checking) + elif isinstance(node, (ast.Try, ast.With)): + _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. + + Returns: + The mark, or None for a function that is not a flow. + """ + for one in node.decorator_list: + 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 == "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 + + +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 _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 { + 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) + # 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) + 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.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) + and _root(node.func.value) in held + ): + if node.func.attr in {"update", "setdefault"}: + wrote = wrote or node.lineno + elif node.func.attr in {"clear", "pop", "popitem"}: + 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 + #: 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 +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, 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, 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. + """ + for node in read.tree.body: + yield from _defined(node, read, asks, {}, skip) + + +def _defined( + node: ast.stmt, + read: _Read, + asks: _Asks, + inherited: dict[str, frozenset[str] | _Crew], + skip: frozenset[int] = frozenset(), +) -> Iterator[Finding]: + """One top-level statement, read for the functions in it.""" + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if id(node) 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, skip) + elif isinstance(node, ast.If): + for held in [*node.body, *node.orelse]: + yield from _defined(held, read, asks, inherited, skip) + + +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.proto) + 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, 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, proto) + if isinstance(said, ast.Subscript) and _root(said.value) == "Optional": + first = next(iter(_elements(said.slice)), None) + return _annotated(first, proto) + if isinstance(said, ast.BinOp) and isinstance(said.op, ast.BitOr): + 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 + # 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 proto: + return frozenset({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.proto)) + 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.proto) + 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.proto) + 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, + model=_shaped_as(value), + ) + 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, + 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) + + +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 + _never(node, scope) + _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. + """ + # 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): + 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 = [_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 + ) + if against_none: + for side in sides: + if isinstance(side, ast.Name): + 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("_"): + 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 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. +# --------------------------------------------------------------------------------------- + + +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 + + +# --------------------------------------------------------------------------------------- +# 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/src/hmz/flows/driving.py b/src/hmz/flows/driving.py index 8b341bb..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", @@ -472,12 +473,13 @@ 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)) + 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 @@ -528,7 +530,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 +545,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 +553,116 @@ 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 -- + 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. + 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. + """ + from .atlas import ATLAS + + if getattr(run, ATLAS, None) is None: + return run + 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. + """ + + 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. + """ + 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. + """ + if self._walk is None: + from .stepping import walking + + self._walk = walking(self._named, self._read, self._entry) + 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: """One agent as whoever hands it to a flow holds it, rather than as a flow does. @@ -741,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 new file mode 100644 index 0000000..c9856c0 --- /dev/null +++ b/src/hmz/flows/prophesying.py @@ -0,0 +1,1750 @@ +"""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, + Kind, + 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, + _annotated, # pyright: ignore[reportPrivateUsage] + _elements, # pyright: ignore[reportPrivateUsage] + _Mark, # 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] +) + +if TYPE_CHECKING: + import os + +__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 +#: 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, + ) + 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( + _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, _gathered(whole), mark, name or _stem(whole), through + ) + found.extend(said) + 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) + + +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, where the + flow ships none, or where what it ships is another of the atlases its file holds. + """ + 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. + 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", + 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 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]: + """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 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 named_as(at.parent if at.name == ENTRY else at) + + +# --------------------------------------------------------------------------------------- +# 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 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] + crews: dict[str, ast.ClassDef] + nodes: dict[str, _Node] + atlases: dict[str, tuple[_Read, _Mark]] + subs: dict[str, str] + protos: dict[str, str] + fields: 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) + held.fields.update(read.field_alias) + 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, + 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. + + 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) + 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: 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: + 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 "" + ) + if config: + found.extend(_settled(config, held, where, params[2].lineno)) + answers = "" if gives == NOTHING else gives + wiring = _Wiring( + whole=whole, + held=held, + where=where, + agents=agents, + takes=takes, + gives=answers, + 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=answers, + config=config, + 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 _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, ...]: + """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. + """ + # 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( + "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 _annotated(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]] + + +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: + """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] = {} + #: 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} + + 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)]): + # 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)) + # 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", + 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): + 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): + 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 + answers = "" + if node.value is not None: + 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 + # 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( + _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, answers)) + + 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 + 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] + # 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. + + 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] + 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))] + 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. + + 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 = self._above(head) + 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[Reads, 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: + 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. + """ + # `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: + 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 + shape = self._shape_of(read) + if shape is None: + 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( + _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( + 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 + 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 + 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. + """ + 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): + agent = bool(_annotated(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 _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. + + 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) + 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( + _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 _Declared( + "atlas", + [("agents", THE_AGENTS), ("said", under.takes)], + under.gives, + rerun=True, + under=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, self.held, 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(read) + return reads + + def _fits( + self, call: ast.Call, called: str, param: str, shape: str, read: Reads + ) -> 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: + 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( + _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: Reads + ) -> 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) -> Reads | 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 Reads(self.names.get(node.id, node.id)) + if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name): + return Reads(self.names.get(node.value.id, node.value.id), node.attr) + return None + + def _shape_of(self, read: Reads) -> 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. + + 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) + ) + + +# --------------------------------------------------------------------------------------- +# 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) 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): + 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, held)) + ) + return said + + +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 + # 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 + + +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: Reads) -> 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/proving.py b/src/hmz/flows/proving.py new file mode 100644 index 0000000..552c4fb --- /dev/null +++ b/src/hmz/flows/proving.py @@ -0,0 +1,657 @@ +"""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 + + +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. + + 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 asyncio + 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 + # 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 + 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 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 + 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): + # 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): + 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/src/hmz/flows/stepping.py b/src/hmz/flows/stepping.py new file mode 100644 index 0000000..fa3c662 --- /dev/null +++ b/src/hmz/flows/stepping.py @@ -0,0 +1,517 @@ +"""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 dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +from .atlas import AGENTS, ATLAS, CONFIG, INPUT, Node, Reads, digest, shipped + +if TYPE_CHECKING: + import os + from collections.abc import Mapping + + from pydantic import BaseModel + + from .agent import Agent + from .atlas import Edge, 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" + +#: 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. +_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 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 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 + 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. + """ + import functools + + from . import inside as which + from . import reading + from .driving import NotAFlow + from .prophesying import named_as, prophesied + + 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(named)) + 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 + 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( + 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 + # what this copies -- so a third mark added later travels without this line moving. + 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. + + 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, 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, or ships one for another atlas. + + 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 + + held = shipped(under) + if held is None: + return None + if held.prophecy is None: + raise NotAFlow( + 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.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( + 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 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. 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( + prophecy=prophecy, + inside=inside, + agents={one: getattr(agents, one) for one in prophecy.agents}, + state=state, + kept=state.setdefault(_DONE, {}), + under="", + beside={}, + ) + 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: + """Walks one prophecy from its way in to its way out. + + Args: + 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. + + Returns: + What the prophecy answers with, which is what the `return` it left by named. + """ + bound: dict[str, Any] = {AGENTS: walk.agents, INPUT: given, CONFIG: config} + seen: dict[str, int] = {} + at = "" + while True: + 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"{walk.under}{node.at}{_VISIT}{visit}" + answered = _answered(walk, bound, node, held) + if node.binds: + bound[node.binds] = answered + at = node.at + + +def _way(walk: _Walk, at: str, bound: dict[str, Any]) -> Edge | None: + """Which way out of one node this run takes. + + Args: + walk: The prophecy being walked. + at: The node it is leaving, and "" for the way in. + bound: What each name holds now. + + Returns: + 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 walk.ways.get(at, ()): + when = edge.when + if ( + when is None + or bool(_read(bound, Reads(when.reads, when.field))) is when.truth + ): + return edge + return None + + +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: + walk: The prophecy being walked. + 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 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. + walk.kept[held] = None + _saved(walk.state) + 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. 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) + return answered + + +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: + walk: The prophecy being walked. + 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(walk, node, held, said) + call = walk.inside.get(node.calls) + if not callable(call): + raise NotAFlow( + 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(walk: _Walk, node: Node, held: str, said: list[Any]) -> Any: + """Runs one supernode, which is a whole prophecy inside this one. + + Args: + 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. + + 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 = walk.prophecy.under(node.under) + if under is None: + 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. 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)) + else: + beside = walk.beside[node.calls] + return _walked( + _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, + ) + + +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/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/src/hmz/sdk/SPEC.md b/src/hmz/sdk/SPEC.md index 7b88147..99a5199 100644 --- a/src/hmz/sdk/SPEC.md +++ b/src/hmz/sdk/SPEC.md @@ -134,6 +134,11 @@ 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 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: ... @@ -143,6 +148,13 @@ 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. +- `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. diff --git a/src/hmz/sdk/flows.py b/src/hmz/sdk/flows.py index b4a749e..3eb6e00 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, Prophecy, Running __all__ = ["Flows", "Flowverses"] @@ -212,6 +212,105 @@ 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. + + 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 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) + # 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, 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: + """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 hmz.flows import inside, prophesied, reading + + 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. + + 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, 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 + + 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") + # "" 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 = Path(beside) / 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_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_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_checking.py b/tests/test_checking.py new file mode 100644 index 0000000..02b4f3d --- /dev/null +++ b/tests/test_checking.py @@ -0,0 +1,945 @@ +"""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", + "unknown-verdict": "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 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 +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)] + 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: + 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_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]()), ] diff --git a/tests/test_flow_interface.py b/tests/test_flow_interface.py index 274d38c..02e7368 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 @@ -21,6 +21,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 @@ -104,28 +105,6 @@ def test_everything_handed_through_is_offered() -> None: assert handed <= set(hmz.flows.__all__) -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. @@ -153,7 +132,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}" ) diff --git a/tests/test_prophesying.py b/tests/test_prophesying.py new file mode 100644 index 0000000..dc6d0c9 --- /dev/null +++ b/tests/test_prophesying.py @@ -0,0 +1,660 @@ +"""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, schema=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)} + + +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" + + +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) + + +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)} + + +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) + + +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"] diff --git a/tests/test_proving.py b/tests/test_proving.py new file mode 100644 index 0000000..3e9bf66 --- /dev/null +++ b/tests/test_proving.py @@ -0,0 +1,400 @@ +"""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 + + +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 == [] diff --git a/tests/test_stepping.py b/tests/test_stepping.py new file mode 100644 index 0000000..eec877c --- /dev/null +++ b/tests/test_stepping.py @@ -0,0 +1,559 @@ +"""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, configures, kept, resumes, wanted +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") == () + + +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") + + +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!" + + +#: 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"]