diff --git a/AGENTS.md b/AGENTS.md index 80e3643..09eb67e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,23 +55,24 @@ The `bias` initializes the mutable `AGENTS.md` in each output workspace. ## Node state -Every executed output agent receives two sibling directory trees: +Every executed output agent receives three sibling directory trees: ```text node/ statespace/ # activation X becomes Y; writable during forward - workspace/ # persistent Parameter W; writable only during optimization + parameter/ # read-only canonical native agent state W + workspace/ # writable temporary episode fork ``` Forward gives every output agent an independent, writable `statespace/.git` with one fetched local ref per input. The agent chooses the merge order, resolves conflicts, edits the statespace, and commits before it finishes. It -uses a sparse, -read-only `workspace/` checkout that contains the global model history. -`loss.backward()` resumes the session with the permissions reversed. The agent -commits workspace changes directly on the global candidate lineage and returns -one directional feedback string per input. `DFM.step()` promotes the completed -candidate model branch. +uses a read-only `parameter/` and a writable `workspace/` episode fork with no +model Git metadata. `loss.backward()` resumes the episode and returns one +owner mutation proposal plus one directional feedback string per input. +Backward accumulates proposals in `.feed` and discards the episode. +`DFM.step()` resumes each persistent owner once with all accumulated feed. It +updates a model candidate that HyTorch promotes atomically. `mn.Linear.reset_parameters()` delegates to `hytorch.mn.init`, just as `torch.nn.Linear` delegates to `torch.nn.init`. Each workspace starts with an @@ -82,39 +83,44 @@ and data. Model checkpoint syntax follows PyTorch with a directory-native representation: `hytorch.save(model.state_dir(), path)` and `model.load_state_dir(hytorch.load(path))`. A StateDir fixes one canonical model -commit and preserves the complete model Git history. It excludes feedback, -sessions, temporary node trees, and unpromoted optimizer candidates. +commit and preserves the complete model Git history. It includes durable +native sessions and harness state. It excludes feedback, credentials, live +runtime state, temporary node trees, and unpromoted optimizer candidates. Forward returns the complete committed statespace, never a special answer file. Do not inject Space contents into the agent prompt. Mount complete directory -trees. `zero_feed()` clears accumulated feedback and discards an unpromoted -candidate branch. It never deletes canonical Git history. +trees. `zero_feed()` clears accumulated feedback and discards an incomplete +step candidate. It never deletes canonical Git history. ## Git semantics Each Space owns its statespace repository and forward history. The private, global model workspace store records initialization and optimizer generations. Feedback is transient text. -Workspace diffs are concrete mutations. DFM is evolutionary: it advances to -every valid child mutation and does not roll back because one immediate result -is worse. +Agent-state diffs are concrete mutations. Agents never receive the private +model repository and never create model commits. DFM is evolutionary: it +advances to every valid child mutation and does not roll back because one +immediate result is worse. -Backward runs dependency-ready nodes with distinct workspace paths in -parallel. Each node commits against one global candidate revision. HyTorch -merges these commits into the global model history. Nodes that share a -workspace path run in sequence. +Backward runs dependency-ready episodes in parallel. Repeated execution of one +Parameter creates separate episode forks. HyTorch does not merge these forks. +`step()` gives all sorted feed records to the persistent owner. Each owner +updates once. HyTorch commits all owner updates in one global candidate. ## Harnesses -`hytorch.harness.Harness` is the execution base class. Built-ins are -`hytorch.harness.pi`, `codex`, and `claude_code`; only Pi executes in v0. Pi -uses OpenAI through the operator's Codex login or `OPENAI_API_KEY`, with +`hytorch.harness.Harness` is the execution base class. Built-ins are `pi`, +`codex`, `claude-code`, `opencode`, `hermes`, and `prime-agent`. Each harness +stores its complete native profile and session inside the Parameter. A resumed +turn returns a new opaque session tip because native compaction can rotate its +identifier. `close()` releases runtime resources but does not delete state. +Pi uses OpenAI through the operator's Codex login or `OPENAI_API_KEY`, with `gpt-5.6-terra` as the default model. One executed graph uses one harness. `model.to(harness)` moves the complete model before a new forward pass. Docker remains external deployment -configuration. HyTorch uses the standard active Docker context and accepts -`HYTORCH_PI_IMAGE` as an image override. Agent variables come from +configuration. Container-capable harnesses use the standard active Docker +context. Agent variables come from `.hytorch.env`, the global HyTorch secrets file, or `HYTORCH_ENV_FILE`. Never load the ordinary project `.env` automatically. @@ -127,7 +133,7 @@ load the ordinary project `.env` automatically. - `hytorch/backward.py` — Loss and feed-Space propagation. - `hytorch/optim/` — Optimizer base and DFM. - `hytorch/space.py` — Space and lowercase `space` factory. -- `hytorch/runtime/` — Dockerized Pi runtime. +- `hytorch/runtime/` — packaged harness runtime assets. - `example/` — Terminal-Bench training and evaluation example. - `tests/` — offline unit tests plus an opt-in real Pi integration test. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef896e..3ed82ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ HyTorch uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - PyTorch-shaped, directory-native model checkpoints with `state_dir()`, `hytorch.save()`, `hytorch.load()`, and `load_state_dir()`. +- Persistent opaque native agent state across forward, backward, compaction, + optimizer promotion, and checkpoints. +- Executable Codex, Claude Code, OpenCode, Hermes, and Prime Agent harnesses. +- Disposable forward episode forks, accumulated owner feed, and one persistent + owner reducer per Parameter in `step()`. ## [0.1.0] - 2026-08-05 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 97cb5b4..6464d2c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,8 +43,9 @@ Pi uses the operator's Codex login by default. To use an OpenAI API key, export Keep each pull request focused. Add tests for behavior changes. Update `README.md`, `SPEC.md`, and `GLOSSARY.md` when a public concept changes. Do not -commit credentials, `.hytorch.env`, generated model workspaces, or agent -session data. +commit credentials, `.hytorch.env`, or generated model workspaces. Durable +agent sessions belong inside generated model state. Do not add them to the +source tree unless they are explicit test fixtures. Use short commit subjects in the imperative form. Explain design decisions and test results in the pull request description. diff --git a/GLOSSARY.md b/GLOSSARY.md index 5c945e1..7849af5 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -12,26 +12,26 @@ | `torch.nn` | `hytorch.mn` | Neural-network versus meta-network namespace | | neuron | agent | One output computation unit | | `nn.Module` | `mn.Module` | Registered owner with dynamic `forward()` topology | -| `nn.Parameter` | `mn.Parameter` | Registered trainable workspace | +| `nn.Parameter` | `mn.Parameter` | Registered persistent native agent state | | `model.parameters()` | `model.parameters()` | Iterator passed to an optimizer | | `nn.Linear(m, n)` | `mn.Linear(m, n)` | Dense mapping from `m` inputs to `n` agents | -| `Linear.weight` | `Linear.weight` | Shape `(out_features,)`; one workspace per agent | +| `Linear.weight` | `Linear.weight` | Shape `(out_features,)`; one native state per agent | | `Linear.bias` | workspace `AGENTS.md` initializer | Initial mutable direction for each output agent | -| parameter value | workspace directory | Persistent instructions, code, tools, examples, and data | +| parameter value | workspace directory | Opaque transcript, memory, instructions, skills, settings, databases, tools, and data | | `torch.nn.init` | `hytorch.mn.init` | In-place workspace initialization | | `torch.manual_seed` | `hytorch.manual_seed` | Seed workspace prior initialization | | autograd tape | retained execution graph | Dynamic forward provenance and saved sessions | | gradient direction | feedback string | Imperative direction for behavior change | | `.grad` | `.feed` | Accumulated downstream directions for one workspace | | loss tensor | `Loss` | Output Space plus terminal directional feedback | -| `loss.backward()` | `loss.backward()` | Update candidates and propagate per-input feedback | -| `optimizer.zero_grad()` | `optimizer.zero_feed()` | Clear feedback and discard an unpromoted candidate | +| `loss.backward()` | `loss.backward()` | Accumulate owner feed and propagate per-input feedback | +| `optimizer.zero_grad()` | `optimizer.zero_feed()` | Clear accumulated feed and an incomplete step candidate | | `torch.optim.Optimizer` | `hytorch.optim.Optimizer` | Own Parameters and update transaction state | | `torch.optim.SGD` | `hytorch.optim.DFM` | Gradient descent versus directional feedback mutation | -| `optimizer.step()` | `optimizer.step()` | Promote the completed candidate model branch | +| `optimizer.step()` | `optimizer.step()` | Reduce each Parameter once and atomically promote it | | learning rate `lr` | mutation temperature `temp` | Semantic update scale and sampling temperature | | optimizer budget | `max_tokens` | Backward agent output-token limit | -| parameter delta | candidate workspace mutation | Proposed change made during backward | +| parameter delta | owner mutation feed | Direction accumulated before one persistent owner update | | updated parameter storage | promoted global Git commit | Canonical model generation after `step()` | | saved forward activations | statespace commit and harness session | Context resumed during backward | | `state_dict()` | `state_dir()` | Immutable handle to the canonical model-state revision | diff --git a/README.md b/README.md index 779a7d6..b1fe57b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ plain-language feedback. - Python 3.11 or later - Git -- Docker +- One supported agent CLI or the packaged Pi Docker runtime ### Installation @@ -105,7 +105,7 @@ print(output.commit) # immutable Git identity The result is another complete Git-backed directory. The agents decide which files to create or change, then commit their work. `output.dir` is the output directory. `output.commit` identifies its exact contents. Inference mode -closes the agent sessions after the result is complete. +closes runtime resources and discards all private agent-state changes. ### Improve the network with feedback @@ -138,10 +138,11 @@ Test malformed inputs before you select an implementation. Keep contradictory evidence and explain how you resolved it. ``` -This lifecycle mirrors PyTorch training. `zero_feed()` clears feedback from the -previous iteration. `backward()` resumes the agents and creates candidate -workspace changes. `step()` promotes all completed changes as one new model -generation. +This lifecycle mirrors PyTorch training. `zero_feed()` clears feed from the +previous iteration. Forward runs a disposable episode fork. `backward()` +resumes the episode and accumulates owner mutation feed. `step()` resumes each +persistent owner once with all accumulated feed. It promotes all updates as +one model generation. ### Save and load model state @@ -171,10 +172,12 @@ model.load_state_dir(hytorch.load("model-state")) ``` A `StateDir` identifies one immutable model commit. The saved directory -contains `MODEL.json`, every registered workspace, and the canonical model Git -history. Loading is strict by default. Pass `strict=False` to permit missing or -unexpected workspace keys with compatible shapes. The save destination must -not already exist. +contains `MODEL.json`, every complete native agent state, and the canonical +model Git history. Native state can include transcripts, memories, compaction +records, skills, settings, and databases. It never includes credentials or +live process state. Loading is strict by default. Pass `strict=False` to permit +missing or unexpected workspace keys with compatible shapes. The save +destination must not already exist. ## PyTorch-shaped composition @@ -199,25 +202,30 @@ directory-backed workspace for each output agent. Its physical weight shape is `(out_features,)`. Every output receives every input Space, so the logical layer is dense. -The `bias` argument initializes each workspace's mutable `AGENTS.md`. -Optimization can later add instructions, code, tools, examples, and data. +The `bias` argument initializes each workspace's mutable `AGENTS.md`. The +native harness and agent can later replace or extend the complete state in any +format. ## How one agent runs -Each output agent receives two sibling directory trees: +Each output agent receives three sibling directory trees: ```text node/ ├── statespace/ # activation: writable during forward -└── workspace/ # parameter: writable during backward +├── parameter/ # read-only canonical native state +└── workspace/ # writable temporary episode fork ``` During forward, the agent merges every input statespace, transforms the merged -tree, and commits the result. Its workspace is read-only. +tree, and commits the result. Its native transcript, memory, and other local +state can change inside the episode, but forward never changes the Parameter. -During backward, HyTorch resumes the same agent session. The statespace is now -read-only. The agent can mutate and commit its candidate workspace. Git records -each activation, workspace diff, and promoted model generation. +During backward, HyTorch resumes the episode. The statespace and Parameter are +read-only. The episode returns one owner proposal and one direction per input. +HyTorch accumulates these proposals in `.feed` and discards the episode. +`step()` resumes the persistent owner once and lets it update its complete +native state from all accumulated feed. ## Harnesses and environment @@ -228,8 +236,26 @@ model.to("pi") model.to(harness="pi", mtype="gpt-5.6-terra") ``` -The built-in harness identities are `pi`, `codex`, and `claude-code`. Only Pi -executes in 0.1.0. Pi uses `gpt-5.6-terra` by default. +The built-in harness identities are `pi`, `codex`, `claude-code`, `opencode`, +`hermes`, and `prime-agent`. Pi uses `gpt-5.6-terra` by default. Each harness +uses its native local profile and session format inside the Parameter. + +| Identity | Runtime | Persisted native state | +|---|---|---| +| `pi` | Packaged Pi SDK runtime | Pi profile and JSONL session | +| `codex` | `codex` CLI | `CODEX_HOME`, transcript, and project memory | +| `claude-code` | `claude` CLI | Claude config, projects, and JSONL session | +| `opencode` | `opencode` CLI | Isolated home and all XDG state directories | +| `hermes` | `hermes` CLI | `HERMES_HOME`, `state.db`, memories, skills, and profile | +| `prime-agent` | `prime-agent` CLI | Profile, JSONL session, RLM children, and session artifacts | + +You can construct a harness when you need a custom binary, model, provider, +or external credential sidecar: + +```python +harness = hytorch.harness.CodexHarness(binary="codex") +model.to(harness) +``` Agent variables come from `~/.config/hytorch/secrets.env`, project `.hytorch.env`, `HYTORCH_ENV_FILE`, and exported shell variables, in increasing @@ -246,10 +272,9 @@ values in prompts or Git state. HyTorch 0.1.0 is the first public alpha release. Run agents in isolated environments and review agent-created changes before production use. -Version 0.1.0 includes Spaces, Parameters, dynamic Module graphs, dense Linear -layers, directional backward feedback, atomic DFM optimizer generations, and -the Dockerized Pi harness. The `codex` and `claude-code` harnesses are reserved -but unavailable. +Version 0.1.0 includes Spaces, native-state Parameters, dynamic Module graphs, +dense Linear layers, directional backward feedback, atomic DFM optimizer +generations, and six native agent harnesses. ## Resources diff --git a/SECURITY.md b/SECURITY.md index f5265a4..d3cde4f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,7 +20,10 @@ data from the report. HyTorch executes coding agents against directory trees and Git repositories. Treat agent output and model-generated code as untrusted. Use isolated Docker environments. Use credentials with the least required privilege. Review -workspace mutations before you use a trained model in a sensitive system. +promoted native agent state before you use a trained model in a sensitive +system. Native state can contain transcripts, tool results, memories, and +executable files. HyTorch rejects Git metadata, escaping symlinks, special +files, states with more than 100,000 files, and states larger than 2 GiB. HyTorch does not load a project `.env` file. Store agent variables in `.hytorch.env`, the global HyTorch secrets file, or the file selected by diff --git a/SPEC.md b/SPEC.md index e5e0758..32308f7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2,40 +2,45 @@ ## Purpose -HyTorch applies PyTorch-shaped ownership and training to Git-backed agent -systems. +HyTorch applies PyTorch-shaped ownership and training to agent networks. ```text Tensor -> statespace Space -Parameter -> trainable workspace +Parameter -> persistent native agent state neuron -> agent autograd graph -> retained execution graph gradient -> directional feedback -parameter delta -> candidate workspace mutation +parameter delta -> accumulated owner mutation feed ``` Calls in `forward()` define the runtime graph. `model.parameters()` supplies -the optimizer. `loss.backward()` creates candidate workspace updates while it -propagates feedback. `optimizer.step()` promotes the completed candidate model -branch. +the optimizer. `loss.backward()` accumulates directional feed while it +propagates feedback. `optimizer.step()` reduces all feed into one update per +Parameter and atomically promotes the model generation. -## Spaces and workspaces +## Spaces and Parameters `hytorch.space(data, *, mtype=None, harness=None, requires_feed=False)` creates -a `Space` from one Git-backed directory. A list of directories creates an -ordered `SpaceBatch`. +a `Space` from one Git-backed directory. A list creates an ordered +`SpaceBatch`. A Space is an activation state. It enters a node as `X`. The node transforms it into output statespace `Y`. -An `mn.Parameter` is a persistent workspace. It enters a node as `W`. Forward -cannot change it. +An `mn.Parameter` is one agent's persistent native state `W`. It can include +the native transcript, compaction records, memories, instructions, skills, +settings, databases, tools, and other local files. The harness defines the +format. HyTorch treats the directory as opaque state. + +A harness can reserve small metadata files inside native state. These files +can identify the stable local project or current session tip. The agent must +not change harness-owned metadata directly. ```text Yᵢ = Agentᵢ(X₀, ..., Xₙ; Wᵢ) ``` -Each model owns one Git repository under: +Each model owns one private Git repository: ```text hytorch/workspaces// @@ -45,87 +50,101 @@ hytorch/workspaces// └── / └── / ├── AGENTS.md - └── ... + └── ``` -Each numbered directory is one complete workspace. `AGENTS.md` is mutable -workspace state. The `bias` constructor value initializes it. Optimization can -change it and can create, update, move, or delete any other workspace file. +The private repository is a HyTorch implementation detail. The agent receives +a plain materialized directory. It does not receive the repository or model +history. The initial `AGENTS.md` contains the `bias` and seeded priors. A +harness or agent can replace it with any native state. + +Credentials and live process state are not Parameters. A harness must keep API +keys, OAuth credentials, sockets, process IDs, bearer tokens, and runtime locks +outside the Parameter. It can attach a temporary credential overlay during a +turn. It must remove the overlay before HyTorch captures state. + +Native agent state must contain normal files, directories, and internal +symlinks. It must not contain `.git`, escaping symlinks, sockets, or other +special files. This rule lets private Git capture the complete state. ## Linear -One output feature is one agent. `mn.Linear(3, 4)` runs four agents in -parallel. Each output agent receives all three input Spaces and owns one -monolithic workspace. `layer.weight.shape` is `(out_features,)`. +One output feature is one persistent agent. `mn.Linear(3, 4)` runs four agents +in parallel. Each agent receives all three input Spaces and owns one monolithic +state directory. `layer.weight.shape` is `(out_features,)`. -The logical graph is dense. A workspace can implement nonlinear behavior +The logical graph is dense. A native state can implement nonlinear behavior across all inputs. -`mn.Linear.reset_parameters()` delegates to `hytorch.mn.init`. Each initial -`AGENTS.md` contains the mutable bias and seeded input priors. +`mn.Linear.reset_parameters()` delegates to `hytorch.mn.init`. `hytorch.manual_seed(seed)` makes prior initialization reproducible. -## Forward +## Harnesses + +`hytorch.harness.Harness` defines three lifecycle operations: + +```text +start(workspace, prompt) -> Result(text, native session tip) +resume(session, workspace, prompt)-> Result(text, new native session tip) +close(session) -> release runtime resources only +``` + +`start()` continues an existing native session when the workspace contains +one. It creates a session only for a new agent. `resume()` can return a new +opaque session tip because native compaction can rotate a session ID. +`close()` must not delete persisted state. -### Runtime placement - -One executed graph uses one registered harness. Mixed per-layer harnesses are -invalid. `model.to(harness)` moves the complete model before a new forward -pass. `model.to(mtype=...)` changes only the default model type. Omitted values -preserve the existing setting. Forward and its matching backward retain the -same harness session. - -Docker configuration is external to the model API. HyTorch uses the active -Docker context and standard `DOCKER_CONTEXT`, `DOCKER_HOST`, `DOCKER_CONFIG`, -and TLS settings. `HYTORCH_PI_IMAGE` overrides the packaged Pi image. HyTorch -resolves the chosen tag to one image ID when the harness first executes. -Local contexts use bind mounts. SSH and TCP contexts use temporary Docker -volumes to upload node state and download the writable result. Read-only -permissions remain volume-mount properties. HyTorch removes temporary remote -volumes after execution. Remote Pi execution requires `OPENAI_API_KEY` because -the local Pi OAuth directory is not available to the remote daemon. - -Agent variables come from the optional global `~/.config/hytorch/secrets.env`, -project `.hytorch.env`, and `HYTORCH_ENV_FILE`, in that order. Exported shell -values override declared keys. HyTorch forwards known provider keys from the -shell without a file. It ignores ordinary `.env`. It supplies merged values -through a temporary mode-0600 Docker env file and deletes it after execution. -Secret values do not enter model state or Git. +Built-in harnesses are `pi`, `codex`, `claude-code`, `opencode`, `hermes`, and +`prime-agent`. Each harness owns its native profile layout and command-line +contract. One executed graph uses one harness. `model.to(harness)` moves the +complete model before a new forward pass. `model.to(mtype=...)` changes the +default model type. + +Agent variables come from global `~/.config/hytorch/secrets.env`, project +`.hytorch.env`, and `HYTORCH_ENV_FILE`, in that order. Exported provider keys +override declared values. HyTorch ignores ordinary `.env`. + +## Forward Each output agent receives this node root: ```text node/ -├── statespace/ # self-contained Git state; read-write -└── workspace/ # sparse global model checkout; read-only +├── statespace/ # writable, self-contained activation Git repository +├── parameter/ # read-only canonical native state +└── workspace/ # writable temporary episode fork ``` +The canonical Parameter remains unchanged. The writable `workspace/` is a +disposable episode fork. Native session writes, transcript growth, compaction, +and memory updates can occur during forward. They are not Parameter updates. + For each output agent, forward does the following: -1. Initialize a new, independent repository in `statespace/`. +1. Create an independent repository in `statespace/`. 2. Create an empty integration commit. -3. Fetch each input commit from its own Space repository into - `refs/hytorch/inputs/`. -4. Materialize the global model history as a read-only sparse checkout of the - current node workspace. -5. Start a persistent harness session. -6. Let the agent inspect its workspace and choose an input merge order. -7. Let the agent merge every input, resolve conflicts, and commit each merge. -8. Let the agent transform and test the statespace. -9. Require the agent to commit all final statespace changes. -10. Treat the end of the harness turn as its completion signal. -11. Validate a clean repository and verify every input is an ancestor of `HEAD`. -12. Create an empty HyTorch seal commit with node metadata. -13. Return the sealed local repository as the output Space. -14. Retain the harness session for one backward pass. - -A forward pass can leave the merged content unchanged. It must still integrate -every input ref and leave a clean committed state. The agent owns its merge and -work commits. HyTorch owns validation and the seal commit. +3. Fetch each input commit into `refs/hytorch/inputs/`. +4. Export canonical state into read-only `parameter/`. +5. Copy it into writable `workspace/` and start an episode session. +6. Let the agent merge every input, resolve conflicts, and commit each merge. +7. Let the agent transform and test the statespace. +8. Require a clean committed statespace. +9. Verify that every input is an ancestor of `HEAD`. +10. Create a HyTorch seal commit with node metadata. +11. Return the sealed repository as the output Space. +12. Retain the episode and native session tip for backward. + +A forward pass can leave merged content unchanged. It must still integrate +every input ref. The agent owns statespace merge and work commits. HyTorch owns +validation and the seal commit. + +Inference uses the same episode behavior. HyTorch closes the runtime and +discards the episode after forward. Inference never changes canonical +agent state. ## Loss and feedback -The core `Loss` contains only one output Space and one non-empty directional +The core `Loss` contains one output Space and one non-empty directional feedback string: ```python @@ -135,60 +154,43 @@ loss = hytorch.Loss( ) ``` -Feedback is an imperative direction of change. It is not a score, objective, -metric, observation bundle, or Git-backed Space. A loss function can invoke an -evaluator agent, inspect the output statespace, use tools, and synthesize this -direction. +Feedback is an imperative direction of change. It is not a score or a Git +state. An evaluator can inspect the output and synthesize this direction. ## Backward -`loss.backward()` traverses the retained graph from outputs to inputs. It uses -one model-wide candidate branch. The canonical model remains unchanged. +`loss.backward()` traverses the retained graph from outputs to inputs. It +accumulates feed. The canonical model remains unchanged. For each ready node, backward does the following: -1. Accumulate all directional feedback from downstream consumers. -2. Resume the exact harness session saved during forward. +1. Accumulate all directions from downstream consumers. +2. Resume the exact native session tip retained by forward. 3. Keep the complete `statespace/` repository read-only. -4. Materialize the latest global candidate as a writable sparse `workspace/` - checkout. It retains model history from initialization but checks out only - the selected node workspace. -5. Let the agent update the workspace, or leave it unchanged. -6. Require the agent to commit all workspace changes. -7. Require one non-empty directional feedback string for every input ref in - the final JSON response. -8. Treat the end of the resumed harness turn as the completion signal. -9. Validate both Git repositories and the structured response. -10. Run dependency-ready nodes with distinct workspace paths in parallel from - one candidate commit. -11. Merge their validated commits into the global candidate branch. -12. Deliver each feedback string to its corresponding input producer. -13. Close and delete the saved agent session. - -The local operation is: +4. Keep the temporary episode `workspace/` writable. +5. Ask for one owner mutation proposal and one direction per input. +6. Accumulate the owner proposal in the Parameter's `.feed`. +7. Require one non-empty upstream direction for each input ref. +8. Accept the new opaque native session tip. +9. Validate that `statespace/` and `parameter/` did not change. +10. Record feed provenance and a stable content digest. +11. Deliver each upstream direction to its input producer. +12. Close runtime resources and discard the episode unless the graph is retained. + +The operation is: ```text -UpdateAndBackward(feedback, X₀, ..., Xₙ, Y; W) - -> W′, feedback₀, ..., feedbackₙ +Backward(feedback, X₀, ..., Xₙ, Y; W) + -> feed(W), feedback₀, ..., feedbackₙ ``` -A node that has multiple downstream consumers receives their feedback strings -separately. It updates its workspace once after all consumers finish. This is -the text-agent equivalent of gradient accumulation. - -One Git repository owns all model workspaces. Each agent commit changes only -its registered workspace path. The global history records the exact trajectory -of every weight and every complete model generation. `step()` can promote all -workspace changes atomically. +A node with multiple consumers receives all feedback strings together. It +emits one owner proposal after all consumers finish. Multiple forward and +backward passes can add feed before one `step()`. A second backward through the +same graph requires `retain_graph=True`, as in PyTorch. -Backward parallelism follows graph dependencies. Nodes in one ready frontier -can use separate Docker containers and commit from the same candidate base. -HyTorch merges commits for distinct workspace paths. It serializes executions -that refer to the same workspace path. An earlier layer waits until it receives -all downstream feedback. - -Every forward session is single-use. HyTorch does not retain a session after -backward. A new model generation requires a new forward pass. +Dependency-ready episodes run in parallel. Repeated use of one Parameter +creates independent episode forks. HyTorch never merges those opaque forks. ## Optimizer @@ -198,8 +200,9 @@ DFM means Directional Feedback Mutation. optimizer = hytorch.optim.DFM(model.parameters(), temp=0.7, max_tokens=10_000) ``` -`temp` controls the semantic scale and sampling temperature of backward -workspace changes. `max_tokens` limits the resumed agent turn. +`temp` states the semantic mutation scale. A harness forwards it only when its +native runtime supports a sampling temperature. `max_tokens` limits a resumed +turn only when the runtime has a matching control. The training lifecycle is: @@ -211,90 +214,81 @@ loss.backward() optimizer.step() ``` -The operations have separate responsibilities: - ```text -zero_feed() clear old feedback and discard an unpromoted candidate -backward() update candidate workspaces and propagate feedback -step() atomically promote the completed candidate branch +zero_feed() clear accumulated feed and discard an incomplete step candidate +backward() accumulate owner feed and propagate per-input feedback +step() reduce each Parameter once and atomically promote the generation ``` -`step()` does not invoke agents. It makes the already committed candidate -workspaces canonical. If backward fails, HyTorch discards the candidate branch -and leaves the canonical model unchanged. +`step()` invokes the persistent owner agent once per Parameter. The owner sees +all sorted feed records and their provenance in a read-only evidence Space. +It updates a writable copy of its canonical native state. HyTorch promotes all +owner updates in one transaction. If one owner fails, HyTorch promotes none +and retains feed for retry or `zero_feed()`. ## Git semantics Each activation Space owns an independent repository: ```text -git init create a self-contained node repository -git fetch import each independent input commit +git init create a node repository +git fetch import independent input commits git merge --no-ff agent integrates input refs git add -A && git commit agent records statespace work -git commit --allow-empty HyTorch seals the node execution +git commit --allow-empty HyTorch seals node execution ``` -One global repository owns all model workspaces: +One private repository owns all model Parameters: ```text -git worktree add create a backward candidate checkout -git init && git commit materialize the agent workspace repository -git add -A && git commit agent records workspace work -git add -A && git commit canonicalize W′ on the candidate branch -git merge --no-ff promote the completed model generation +git worktree add create the model candidate +plain directory export materialize one native agent state +filesystem copy capture the completed opaque state +git add -A && git commit HyTorch records the candidate generation +git merge --no-ff step promotes the generation ``` -Feedback is transient text and does not use a third Git repository. Git gives -Spaces and model generations stable identity, ancestry, diffs, audit history, -and atomic promotion. +Agents never receive model Git metadata and never create model commits. Git +gives canonical model generations stable identity, diffs, history, and atomic +promotion. ## Model state directories -HyTorch serializes model state as a directory because each Parameter element is -already a complete directory. The public form follows PyTorch checkpoint -syntax: +Model checkpoints use directory-native PyTorch-shaped syntax: ```python hytorch.save(model.state_dir(), path) model.load_state_dir(hytorch.load(path)) ``` -`model.state_dir()` returns a `StateDir` fixed to the canonical model commit at -the time of the call. It does not include an unpromoted DFM candidate. -`hytorch.save()` creates a self-contained Git directory at that exact commit. -The saved state contains `MODEL.json`, all registered workspace directories, -and the canonical model history. It excludes feedback, active harness sessions, -temporary node trees, and optimizer candidates. - -`hytorch.load()` validates the repository root, committed `MODEL.json`, format, -and workspace paths. It returns a `StateDir`; it does not modify a model. -`model.load_state_dir()` copies matching workspaces into an initialized model -and records one canonical load commit. The default `strict=True` requires the -saved and destination workspace keys to match exactly. `strict=False` permits -missing and unexpected keys, but shape and module-type mismatches remain -errors. The return value reports missing and unexpected keys in the same style -as PyTorch's `load_state_dict()`. - -State capture and load require a clean canonical model worktree. Loading while -an optimizer candidate is pending is an error. Validation must finish before -HyTorch changes any destination workspace. +`model.state_dir()` fixes one canonical model commit. It excludes optimizer +feed and temporary episodes. It includes every durable native agent +state, including sessions, transcripts, memories, compaction records, and +session artifacts that its harness stores in the Parameter. + +It excludes temporary node trees, credential overlays, live processes, +sockets, locks, and other deployment state. + +Loading validates the complete checkpoint before it changes any destination +Parameter. `strict=True` requires matching workspace keys. `strict=False` +permits missing and unexpected keys, but shape and module-type mismatches are +errors. ## Required invariants -1. One output feature executes one agent. +1. One output feature owns one persistent agent state. 2. Every dense output agent receives every input feature. -3. Each numbered model directory is one complete trainable workspace. -4. `AGENTS.md` is mutable workspace state initialized by `bias`. -5. Forward can modify only `statespace/`. -6. Backward can modify only the candidate `workspace/`. -7. Feedback is non-empty directional text. -8. Each node produces one feedback string per input edge. -9. A node waits for all downstream feedback before it runs backward once. -10. Agents own local work commits. HyTorch owns seal and canonical commits. -11. Backward closes each resumed forward session. -12. Only `optimizer.step()` promotes candidate workspace commits. -13. `zero_feed()` never changes committed canonical workspace history. -14. A StateDir identifies one committed, immutable model generation. -15. Saved model state never includes transient feedback, sessions, or candidates. -16. A failed state load leaves every destination workspace unchanged. +3. A harness defines the content and format of its native state. +4. The agent never sees the private model repository. +5. Forward can modify only its episode workspace and statespace. +6. Backward can modify only its episode workspace. +7. Canonical state is immutable until `step()`. +8. `zero_feed()` clears feed and discards an incomplete step candidate. +9. Feedback is non-empty directional text. +10. Each node produces one upstream direction per input edge. +11. A node waits for all downstream feedback before backward runs once. +12. Repeated Parameter executions use isolated episode forks. +13. Harness credentials and process state never enter a Parameter. +14. HyTorch owns all model commits and promotion. +15. A StateDir identifies one committed, immutable model generation. +16. A failed load leaves every destination Parameter unchanged. diff --git a/example/README.md b/example/README.md index c145895..571aa45 100644 --- a/example/README.md +++ b/example/README.md @@ -1,5 +1,9 @@ -# HyTorch Terminal-Bench example +# HyTorch examples [`terminal_bench/`](terminal_bench/) contains an experimental training and evaluation harness for Terminal-Bench 2.1. It downloads the upstream benchmark at a pinned revision. See its local README for requirements and commands. + +[`fft_discovery/`](fft_discovery/) contains a resumable `1 → 3 → 2 → 1` +research network. It searches for smaller exact DFT circuits and evaluates them +with a trusted exact verifier. diff --git a/example/fft_discovery/README.md b/example/fft_discovery/README.md new file mode 100644 index 0000000..1145464 --- /dev/null +++ b/example/fft_discovery/README.md @@ -0,0 +1,188 @@ +# Exact FFT circuit discovery + +This example trains six persistent agents to search for a smaller exact DFT +circuit. A trusted verifier checks every candidate. Each completed generation +has a model checkpoint, a statespace checkpoint, an evaluation report, and +token-use metadata. + +The example can prove a new upper bound under one fixed cost model. It cannot +prove that an algorithm is globally optimal. It also cannot guarantee that a +run will find an improvement. + +## Network + +The `1 → 3 → 2 → 1` graph has these roles: + +```text + algebra and literature ─┐ + ├─ proposal ───────┐ +research state ───────── search engineering ────┤ ├─ curator + ├─ adversarial ────┘ + exact verification ────┘ +``` + +The agents keep search code, algebra, and failure lessons in their persistent +workspaces. The statespace keeps candidates and reports. The controller gives +exact evaluation results to the graph as directional feedback. + +## Executable search and novelty + +Each statespace contains `tools/fft_search.py`. It performs exact semantic +common-subexpression elimination and dead-code removal. It verifies the result +before it writes a candidate. It also accepts repeated `--known` paths and +refuses to write a structure that is already known: + +```sh +python tools/fft_search.py \ + control/target.json \ + incumbent/circuit.json \ + submissions/current/search/simplified.json \ + --known incumbent \ + --known submissions/archive +``` + +This is a safe first search primitive. Agents can extend it with bounded local +synthesis, SAT, SMT, or other exact methods. Numerical equality is not enough. + +The controller computes a canonical SHA-256 identity from `format`, `n`, +`operations`, and `outputs`. It ignores descriptions, file names, formatting, +and other prose. It compares each valid submission with the incumbent, all +prior generations, and earlier submissions in the same generation. A duplicate +remains visible in the report, but it cannot become the generation winner. + +## First calibration run + +Create a standalone Git statespace with an `N=8` direct-DFT incumbent: + +```sh +uv run python -m example.fft_discovery.prepare fft-calibration --n 8 +``` + +This is a deliberately weak incumbent. Use it to test the complete pipeline. +It is not a scientific frontier. Training refuses this target unless you pass +`--allow-calibration`. + +Run one generation: + +```sh +uv run python -m example.fft_discovery.train \ + fft-calibration \ + --run-dir fft-calibration-run \ + --allow-calibration \ + --generations 1 \ + --max-hours 2 \ + --max-total-tokens 250000 +``` + +Run a second generation from the last complete checkpoint: + +```sh +uv run python -m example.fft_discovery.train \ + --run-dir fft-calibration-run \ + --resume \ + --allow-calibration \ + --generations 1 \ + --max-hours 4 \ + --max-total-tokens 500000 +``` + +`--max-hours` and `--max-total-tokens` are cumulative run limits. The +controller checks them between generations. One active generation can exceed a +limit. An interrupted generation is discarded. The prior checkpoint remains +valid. + +Inspect `fft-calibration-run/latest.json` after each generation. Inspect the +matching `generation-NNNN/state/reports/` directory for exact candidate +results. + +## Frozen research target + +A record attempt needs a separately audited target JSON file. It must use this +shape: + +```json +{ + "format": "hytorch-fft-target-v1", + "status": "frozen", + "n": 16, + "transform": "unscaled complex-input DFT with negative exponential sign", + "input_order": "x0.real, x0.imag, x1.real, x1.imag, ...", + "output_order": "X0.real, X0.imag, X1.real, X1.imag, ...", + "cost_model": { + "add": 1, + "sub": 1, + "nontrivial_real_scale": 1, + "negation": 0, + "multiplication_by_one_or_minus_one": 0, + "fused_operations": "not allowed" + }, + "incumbent": { + "name": "audited published construction", + "total_operations": 123, + "source": "primary-source citation with theorem or table location" + }, + "limits": { + "max_operations": 10000 + } +} +``` + +Replace the example count and source with audited values. Do not infer the +count from a different transform, scaling convention, or arithmetic model. + +The included [`targets/n32.md`](targets/n32.md) audit freezes a practical +frontier target at 456 operations. It also includes commands to generate and +verify the exact split-radix incumbent before training. + +Prepare the target: + +```sh +uv run python -m example.fft_discovery.prepare \ + fft-frontier-state \ + --target target.json +``` + +An incumbent circuit is optional. If one is available, add +`--incumbent incumbent.json`. Preparation requires it to pass the exact +verifier at the declared count. + +## Bounded overnight run + +First complete one or two calibration generations. Confirm that at least one +candidate is valid, checkpoints resume, and token use is acceptable. Then run +a frozen target with explicit limits: + +```sh +uv run python -m example.fft_discovery.train \ + fft-frontier-state \ + --run-dir fft-frontier-run \ + --generations 50 \ + --max-hours 10 \ + --max-total-tokens 2000000 \ + --max-stagnant 12 +``` + +The run stops after a trusted candidate beats the frozen count. Pass +`--continue-after-record` only if you want it to search for further reductions. +Use `--resume` after an interruption. Do not pass the source statespace during +a resumed run. + +## Trust boundary + +The candidate format is in `seed/CIRCUIT.md`. Verification uses exact rational +arithmetic in `Q(ω)`. It does not use floating-point tolerance. + +Agents can edit files in their statespace. They cannot change the acceptance +result. The controller: + +- Reads the canonical target from the input checkpoint. +- Runs the package copy of `verifier.py` outside the agent statespace. +- Restores `control/target.json`, `tools/fft_verify.py`, and + `tools/fft_search.py` after each generation. +- Promotes only a candidate that passes exact transform equivalence. +- Rejects known circuit structures even if their prose or file name changed. +- Stores each generation in a new immutable checkpoint directory. + +A verified lower count supports an arithmetic-count claim only. A practical +speed claim needs an optimized implementation, fixed hardware, and comparison +with current FFT libraries. diff --git a/example/fft_discovery/__init__.py b/example/fft_discovery/__init__.py new file mode 100644 index 0000000..05e215b --- /dev/null +++ b/example/fft_discovery/__init__.py @@ -0,0 +1,5 @@ +"""HyTorch FFT algorithm-discovery example.""" + +from .network import FFTDiscoveryNetwork + +__all__ = ["FFTDiscoveryNetwork"] diff --git a/example/fft_discovery/generate.py b/example/fft_discovery/generate.py new file mode 100644 index 0000000..cb360ab --- /dev/null +++ b/example/fft_discovery/generate.py @@ -0,0 +1,30 @@ +"""Generate a reproducible exact FFT incumbent circuit.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .verifier import split_radix_circuit + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("destination") + parser.add_argument("--n", type=int, default=32) + args = parser.parse_args() + + destination = Path(args.destination) + if destination.exists(): + raise FileExistsError(destination) + circuit = split_radix_circuit(args.n) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text( + json.dumps(circuit, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(f"candidate={destination.resolve()}") + + +if __name__ == "__main__": + main() diff --git a/example/fft_discovery/network.py b/example/fft_discovery/network.py new file mode 100644 index 0000000..37e830b --- /dev/null +++ b/example/fft_discovery/network.py @@ -0,0 +1,99 @@ +"""A small research network for exact FFT algorithm discovery.""" + +from __future__ import annotations + +import hytorch + + +class FFTDiscoveryNetwork(hytorch.mn.Module): + """Three specialists, two reviewers, and one final curator.""" + + def __init__(self) -> None: + super().__init__() + self.theory = hytorch.mn.Linear( + 1, + 1, + bias=( + "Act as the FFT algebra and literature specialist. Establish exact " + "definitions, derive useful decompositions, and audit primary sources. " + "Write owned artifacts under research/theory/. Do not claim a record " + "without a traceable source and a matching cost model." + ), + ) + self.search = hytorch.mn.Linear( + 1, + 1, + bias=( + "Act as the algorithm-search engineer. Study executable search methods " + "for FFT straight-line programs, including symbolic rewriting, common " + "subexpression elimination, SAT or SMT search, and evolutionary search. " + "Start by running tools/fft_search.py on each promising circuit. Extend " + "it with bounded exact local synthesis when its built-in semantic CSE " + "does not improve the circuit. Keep machine-readable search logs under " + "research/search/. Put only new exact candidate circuits under " + "submissions/current/search/. Verify each candidate with " + "tools/fft_verify.py. Do not submit renamed copies of known circuits." + ), + ) + self.verification = hytorch.mn.Linear( + 1, + 1, + bias=( + "Act as the exact-verification and cost-model specialist. Define how to " + "prove transform equivalence with exact algebraic arithmetic and how to " + "count permitted operations. Write owned artifacts under " + "research/verification/. Independently run tools/fft_verify.py on " + "candidate circuits. Compare structural hashes with the archive. Reject " + "ambiguous, duplicate, or floating-point-only claims." + ), + ) + self.proposer = hytorch.mn.Linear( + 3, + 1, + bias=( + "Synthesize the three specialist branches into one concrete discovery " + "proposal. Select a bounded FFT target with a published incumbent, an " + "exact certificate format, and a feasible CLI search plan. Write the " + "proposal under proposals/primary/. Build an exact circuit when possible. " + "Put it under submissions/current/proposer/. Never edit control/ or " + "incumbent/. If no new candidate exists, submit no circuit." + ), + ) + self.critic = hytorch.mn.Linear( + 3, + 1, + bias=( + "Audit the specialist branches adversarially. Find unsupported novelty " + "claims, mismatched cost models, verification gaps, and targets that are " + "too expensive for the available compute. Write the audit under " + "reviews/adversarial/. Try to repair or simplify candidates. Put each " + "new verified alternative under submissions/current/critic/. Reject a " + "candidate if its only change is prose, formatting, or a file name." + ), + ) + self.curate = hytorch.mn.Linear( + 2, + 1, + bias=( + "Reconcile the proposal and adversarial review. Produce TARGET.md with " + "one precise research target, SOURCES.md with primary citations, and " + "VERIFIER.md with the exact acceptance contract. Preserve contrary " + "evidence. Inspect the machine target in control/target.json. Select or " + "construct the strongest exact circuit and write it to " + "submissions/current/curator.json. Run tools/fft_verify.py before the " + "final commit. Do not resubmit the incumbent when no new structure was " + "found. Do not report an improvement unless it verifies." + ), + ) + + def forward(self, state: hytorch.Space, *, task: str) -> hytorch.Space: + theory = self.theory(state, task=task)[0] + search = self.search(state, task=task)[0] + verification = self.verification(state, task=task)[0] + evidence = (theory, search, verification) + proposal = self.proposer(*evidence, task=task)[0] + critique = self.critic(*evidence, task=task)[0] + return self.curate(proposal, critique, task=task)[0] + + +__all__ = ["FFTDiscoveryNetwork"] diff --git a/example/fft_discovery/prepare.py b/example/fft_discovery/prepare.py new file mode 100644 index 0000000..b2ad02d --- /dev/null +++ b/example/fft_discovery/prepare.py @@ -0,0 +1,162 @@ +"""Prepare a standalone Git statespace for FFT discovery.""" + +from __future__ import annotations + +import argparse +import dataclasses +import json +import os +import shutil +import subprocess +from pathlib import Path + +from .verifier import ( + COST_MODEL, + INPUT_ORDER, + OUTPUT_ORDER, + TARGET_FORMAT, + TRANSFORM, + Target, + direct_dft_circuit, + verify_circuit, +) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("destination") + parser.add_argument("--n", type=int, default=8) + parser.add_argument( + "--target", + help="frozen frontier target JSON; omit it to create a calibration target", + ) + parser.add_argument( + "--incumbent", help="optional incumbent circuit for a frozen target" + ) + args = parser.parse_args() + prepare( + Path(args.destination), + n=args.n, + target_path=Path(args.target) if args.target else None, + incumbent_path=Path(args.incumbent) if args.incumbent else None, + ) + + +def prepare( + destination: Path, + *, + n: int = 8, + target_path: Path | None = None, + incumbent_path: Path | None = None, +) -> None: + destination = destination.resolve() + if destination.exists(): + raise FileExistsError(destination) + if target_path is None and incumbent_path is not None: + raise ValueError("--incumbent requires --target") + incumbent_value = None + incumbent_result = None + if target_path is None: + incumbent_value = direct_dft_circuit(n) + provisional = Target(n, "calibration", 1, "direct DFT", "generated", 100_000) + incumbent_result = verify_circuit(incumbent_value, provisional) + target_value = { + "format": TARGET_FORMAT, + "status": "calibration", + "n": n, + "transform": TRANSFORM, + "input_order": INPUT_ORDER, + "output_order": OUTPUT_ORDER, + "cost_model": COST_MODEL, + "incumbent": { + "name": "generated direct DFT calibration circuit", + "total_operations": incumbent_result.total_operations, + "source": "tools/fft_verify.py direct_dft_circuit", + }, + "limits": { + "max_operations": max(10_000, len(incumbent_value["operations"]) * 4) + }, + } + else: + target_value = json.loads(target_path.read_text(encoding="utf-8")) + target = Target.from_dict(target_value) + if target.status != "frozen": + raise ValueError("a supplied frontier target must have status 'frozen'") + if incumbent_path is not None: + incumbent_value = json.loads(incumbent_path.read_text(encoding="utf-8")) + incumbent_result = verify_circuit( + incumbent_value, + target, + ) + if incumbent_result.total_operations != target.incumbent_total: + raise ValueError( + "incumbent circuit must verify at the target incumbent count" + ) + Target.from_dict(target_value) + + seed = Path(__file__).with_name("seed") + shutil.copytree(seed, destination) + Path(destination, "control").mkdir() + Path(destination, "incumbent").mkdir() + Path(destination, "submissions/current").mkdir(parents=True) + Path(destination, "submissions/archive").mkdir() + Path(destination, "reports").mkdir() + Path(destination, "tools").mkdir(exist_ok=True) + shutil.copy2( + Path(__file__).with_name("verifier.py"), destination / "tools/fft_verify.py" + ) + shutil.copy2( + Path(__file__).with_name("search.py"), destination / "tools/fft_search.py" + ) + Path(destination, "submissions/current/README.md").write_text( + "# Current submissions\n\nWrite this generation's candidate JSON files here.\n", + encoding="utf-8", + ) + if incumbent_value is not None and incumbent_result is not None: + _write_json(destination / "incumbent/circuit.json", incumbent_value) + _write_json( + destination / "incumbent/verification.json", + dataclasses.asdict(incumbent_result), + ) + _write_json(destination / "control/target.json", target_value) + _write_json( + destination / "reports/preparation.json", + { + "target_status": target_value["status"], + "warning": ( + "Calibration mode proves the pipeline only. It cannot establish a " + "scientific record." + if target_value["status"] == "calibration" + else "The operator supplied a frozen frontier target." + ), + }, + ) + _init_git(destination) + print(f"state_dir={destination}") + print(f"target_status={target_value['status']}") + + +def _write_json(path: Path, value) -> None: + path.write_text( + json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + +def _init_git(root: Path) -> None: + env = dict(os.environ) + env.update( + GIT_AUTHOR_NAME="HyTorch", + GIT_AUTHOR_EMAIL="hytorch@localhost", + GIT_COMMITTER_NAME="HyTorch", + GIT_COMMITTER_EMAIL="hytorch@localhost", + ) + for args in ( + ("init", "--quiet", "--initial-branch=main"), + ("add", "-A"), + ("commit", "--quiet", "-m", "Initialize FFT discovery state"), + ): + subprocess.run(["git", "-C", root, *args], env=env, check=True) + + +if __name__ == "__main__": + main() diff --git a/example/fft_discovery/run.py b/example/fft_discovery/run.py new file mode 100644 index 0000000..fb43c31 --- /dev/null +++ b/example/fft_discovery/run.py @@ -0,0 +1,42 @@ +"""Run the FFT target-selection network once in inference mode.""" + +from __future__ import annotations + +import argparse + +import hytorch + +from .network import FFTDiscoveryNetwork + +DEFAULT_MODEL = "gpt-5.6-terra" +RESEARCH_TASK = """\ +Select one machine-verifiable frontier problem in exact FFT algorithm discovery. +Start from the files in the statespace. Audit current primary literature before +choosing a target. The final state must define the transform, allowed constants, +equivalence domain, arithmetic cost model, published incumbent, exact certificate, +independent verifier, search budget, and stop conditions. Prefer a small fixed +transform that can support repeated local experiments. This run selects and +specifies the research problem. It does not claim a new FFT result. +""" + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("state", help="standalone Git directory with the research seed") + parser.add_argument("--model", default=DEFAULT_MODEL) + parser.add_argument("--provider", default="openai-codex") + args = parser.parse_args() + + harness = hytorch.harness.PiHarness(provider=args.provider) + model = FFTDiscoveryNetwork().to(harness, mtype=args.model) + state = hytorch.space(args.state, harness=harness) + with hytorch.inference_mode(): + output = model(state, task=RESEARCH_TASK) + + print(f"output_dir={output.dir}") + print(f"output_commit={output.commit}") + print(f"workspace_store={model._parameter_store.root}") + + +if __name__ == "__main__": + main() diff --git a/example/fft_discovery/search.py b/example/fft_discovery/search.py new file mode 100644 index 0000000..5c9fab4 --- /dev/null +++ b/example/fft_discovery/search.py @@ -0,0 +1,167 @@ +"""Executable exact simplification and novelty filter for FFT circuits.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +try: + from .verifier import ( + Cyclotomic, + Target, + VerificationError, + _combine_forms, + structural_fingerprint, + verify_circuit, + ) +except ImportError: # Standalone copy in a research statespace. + from fft_verify import ( # type: ignore[no-redef] + Cyclotomic, + Target, + VerificationError, + _combine_forms, + structural_fingerprint, + verify_circuit, + ) + + +def simplify_circuit(candidate: dict[str, Any], target: Target) -> dict[str, Any]: + """Merge exact equivalent registers, then remove dead operations.""" + verify_circuit(candidate, target) + field = Cyclotomic(target.n) + input_count = 2 * target.n + forms: list[dict[int, tuple]] = [{index: field.one} for index in range(input_count)] + form_register = {_form_key(form): index for index, form in enumerate(forms)} + remap = list(range(input_count)) + kept: list[dict[str, Any]] = [] + + for operation in candidate["operations"]: + kind = operation["op"] + rewritten = dict(operation) + rewritten["a"] = remap[operation["a"]] + left = forms[rewritten["a"]] + if kind in {"add", "sub"}: + rewritten["b"] = remap[operation["b"]] + form = _combine_forms( + field, + left, + forms[rewritten["b"]], + subtract=kind == "sub", + ) + elif kind == "neg": + form = {index: field.neg(value) for index, value in left.items()} + else: + constant = field.parse(operation["constant"]) + form = { + index: product + for index, value in left.items() + if (product := field.mul(constant, value)) != field.zero + } + key = _form_key(form) + existing = form_register.get(key) + if existing is None: + existing = input_count + len(kept) + kept.append(rewritten) + forms.append(form) + form_register[key] = existing + remap.append(existing) + + outputs = [remap[register] for register in candidate["outputs"]] + operations, outputs = _remove_dead(input_count, kept, outputs) + result = { + "format": candidate["format"], + "n": candidate["n"], + "description": "Exact semantic CSE and dead-code simplification.", + "operations": operations, + "outputs": outputs, + } + verify_circuit(result, target) + return result + + +def known_fingerprints(paths: list[Path]) -> set[str]: + """Load structural identities from files or directory trees.""" + fingerprints: set[str] = set() + for path in paths: + files = path.rglob("*.json") if path.is_dir() else (path,) + for file in files: + try: + fingerprints.add(structural_fingerprint(json.loads(file.read_text()))) + except (OSError, json.JSONDecodeError, VerificationError): + continue + return fingerprints + + +def _form_key(form: dict[int, tuple]) -> tuple: + return tuple(sorted(form.items())) + + +def _remove_dead( + input_count: int, operations: list[dict[str, Any]], outputs: list[int] +) -> tuple[list[dict[str, Any]], list[int]]: + live: set[int] = set() + stack = list(outputs) + while stack: + register = stack.pop() + if register < input_count: + continue + index = register - input_count + if index in live: + continue + live.add(index) + operation = operations[index] + stack.append(operation["a"]) + if operation["op"] in {"add", "sub"}: + stack.append(operation["b"]) + + register_map = {index: index for index in range(input_count)} + compact: list[dict[str, Any]] = [] + for index, operation in enumerate(operations): + old_register = input_count + index + if index not in live: + continue + rewritten = dict(operation) + rewritten["a"] = register_map[operation["a"]] + if operation["op"] in {"add", "sub"}: + rewritten["b"] = register_map[operation["b"]] + register_map[old_register] = input_count + len(compact) + compact.append(rewritten) + return compact, [register_map[register] for register in outputs] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Simplify one exact FFT circuit and reject known structures." + ) + parser.add_argument("target") + parser.add_argument("candidate") + parser.add_argument("output") + parser.add_argument( + "--known", + action="append", + default=[], + help="known circuit file or directory; repeat as needed", + ) + args = parser.parse_args() + target = Target.load(args.target) + source = json.loads(Path(args.candidate).read_text(encoding="utf-8")) + before = verify_circuit(source, target) + result = simplify_circuit(source, target) + after = verify_circuit(result, target) + fingerprint = structural_fingerprint(result) + if fingerprint in known_fingerprints([Path(value) for value in args.known]): + raise SystemExit(f"duplicate_structure={fingerprint}") + Path(args.output).parent.mkdir(parents=True, exist_ok=True) + Path(args.output).write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(f"before_total={before.total_operations}") + print(f"after_total={after.total_operations}") + print(f"structural_sha256={fingerprint}") + print(f"output={args.output}") + + +if __name__ == "__main__": + main() diff --git a/example/fft_discovery/seed/CIRCUIT.md b/example/fft_discovery/seed/CIRCUIT.md new file mode 100644 index 0000000..d9674ee --- /dev/null +++ b/example/fft_discovery/seed/CIRCUIT.md @@ -0,0 +1,50 @@ +# Exact circuit format + +Candidate files use `hytorch-fft-circuit-v1` JSON. The target defines `N`. +Registers `0` through `2N - 1` are complex input components in this order: + +```text +x0.real, x0.imag, x1.real, x1.imag, ... +``` + +Each operation appends one register. The allowed operations are: + +```json +{"op": "add", "a": 0, "b": 1} +{"op": "sub", "a": 0, "b": 1} +{"op": "neg", "a": 0} +{"op": "scale", "a": 0, "constant": {"basis": {"0": "1/2"}}} +``` + +Constants belong to `Q(ω)`, where `ω = exp(-2πi/N)`. The `basis` object gives +rational coefficients for `1, ω, ..., ω^(N/2 - 1)`. The relation +`ω^(N/2) = -1` applies. Every scale constant must be real under complex +conjugation. + +The `outputs` list contains `2N` register numbers in this order: + +```text +X0.real, X0.imag, X1.real, X1.imag, ... +``` + +Example container: + +```json +{ + "format": "hytorch-fft-circuit-v1", + "n": 8, + "description": "Candidate description", + "operations": [], + "outputs": [] +} +``` + +Run the statespace copy of the verifier before submission: + +```sh +python tools/fft_verify.py \ + control/target.json submissions/current/candidate.json +``` + +The training process uses a separate trusted copy of the verifier. Editing the +statespace copy cannot change the external result. diff --git a/example/fft_discovery/seed/COST_MODEL.md b/example/fft_discovery/seed/COST_MODEL.md new file mode 100644 index 0000000..552c475 --- /dev/null +++ b/example/fft_discovery/seed/COST_MODEL.md @@ -0,0 +1,18 @@ +# Arithmetic cost model + +The machine target in `control/target.json` is authoritative. + +The verifier counts these live real-arithmetic operations: + +- Real addition: 1. +- Real subtraction: 1. +- Multiplication by a nontrivial real constant: 1. + +Real negation and multiplication by `1` or `-1` are free. Fused operations are +not available. Circuit depth is a secondary metric. Dead operations do not +contribute to the score. + +The transform is an unscaled complex-input DFT with a negative exponential +sign. Inputs and outputs use interleaved real and imaginary components. + +Do not compare counts from different transform conventions or cost models. diff --git a/example/fft_discovery/seed/README.md b/example/fft_discovery/seed/README.md new file mode 100644 index 0000000..f96b385 --- /dev/null +++ b/example/fft_discovery/seed/README.md @@ -0,0 +1,21 @@ +# Exact FFT discovery research state + +This directory contains the reusable seed for a HyTorch FFT discovery state. +The preparation command adds a machine-readable target, a trusted-verifier +copy, an incumbent when available, and submission directories. + +The research state should eventually contain: + +- A frozen transform definition and arithmetic cost model. +- A catalog of published incumbents with primary sources. +- An executable candidate representation. +- An exact, independent verifier. +- A reproducible search program. +- A verified Pareto frontier of candidate circuits. + +Write candidate JSON files under `submissions/current/`. Run +`tools/fft_verify.py` before the final commit. Do not edit `control/` or +`incumbent/`. + +Keep generated evidence and rejected hypotheses. Do not keep credentials or +unlicensed copies of papers in this repository. diff --git a/example/fft_discovery/seed/RESEARCH.md b/example/fft_discovery/seed/RESEARCH.md new file mode 100644 index 0000000..419cbbc --- /dev/null +++ b/example/fft_discovery/seed/RESEARCH.md @@ -0,0 +1,24 @@ +# Research objective + +Find an exact FFT straight-line program that improves a published arithmetic +operation count for one fixed transform. + +## Phase 1: target audit + +Audit the prepared transform size and computational model. Establish that the +published incumbent uses the same model. Preserve precise primary-source +locations. + +The initial target should be small enough for repeated local search. Prefer a +case with a clear gap between a published construction and a known lower bound. + +## Phase 2: search + +Implement multiple candidate generators. Preserve every verified improvement +and the complete evidence needed to reproduce it. + +## Phase 3: validation + +Verify the final circuit independently. Audit the operation count, novelty, +numerical stability, and reproducibility. Use measured runtime only for a +separate implementation-performance claim. diff --git a/example/fft_discovery/seed/candidates/README.md b/example/fft_discovery/seed/candidates/README.md new file mode 100644 index 0000000..8c5d095 --- /dev/null +++ b/example/fft_discovery/seed/candidates/README.md @@ -0,0 +1,5 @@ +# Candidate circuits + +Store generator code, candidate families, and analysis here. Put the final JSON +files for the current generation under `submissions/current/`. A candidate is +not a result until the trusted verifier accepts it. diff --git a/example/fft_discovery/seed/literature/README.md b/example/fft_discovery/seed/literature/README.md new file mode 100644 index 0000000..8a8e09a --- /dev/null +++ b/example/fft_discovery/seed/literature/README.md @@ -0,0 +1,5 @@ +# Literature evidence + +Record primary sources, exact claims, transform conventions, cost models, and +candidate encodings here. Use stable URLs, DOI values, and page or theorem +locations when available. diff --git a/example/fft_discovery/seed/verification/README.md b/example/fft_discovery/seed/verification/README.md new file mode 100644 index 0000000..56c5a93 --- /dev/null +++ b/example/fft_discovery/seed/verification/README.md @@ -0,0 +1,8 @@ +# Verification research + +Store independent checks, proof notes, and verifier audits here. + +The executable verifier is `tools/fft_verify.py`. The training controller uses +a separate trusted package copy. It proves exact linear equivalence and reports +the live operation count. Floating-point tests are useful diagnostics, but they +are not final correctness evidence. diff --git a/example/fft_discovery/targets/n32.json b/example/fft_discovery/targets/n32.json new file mode 100644 index 0000000..e41f145 --- /dev/null +++ b/example/fft_discovery/targets/n32.json @@ -0,0 +1,24 @@ +{ + "cost_model": { + "add": 1, + "fused_operations": "not allowed", + "multiplication_by_one_or_minus_one": 0, + "negation": 0, + "nontrivial_real_scale": 1, + "sub": 1 + }, + "format": "hytorch-fft-target-v1", + "incumbent": { + "name": "32-point split-radix FFT", + "source": "Haynal and Haynal, Generating and Searching Families of FFT Algorithms, arXiv:1103.5740v2, Table 1; Johnson and Frigo, IEEE TSP 55(1), 2007", + "total_operations": 456 + }, + "input_order": "x0.real, x0.imag, x1.real, x1.imag, ...", + "limits": { + "max_operations": 5000 + }, + "n": 32, + "output_order": "X0.real, X0.imag, X1.real, X1.imag, ...", + "status": "frozen", + "transform": "unscaled complex-input DFT with negative exponential sign" +} diff --git a/example/fft_discovery/targets/n32.md b/example/fft_discovery/targets/n32.md new file mode 100644 index 0000000..19383e0 --- /dev/null +++ b/example/fft_discovery/targets/n32.md @@ -0,0 +1,76 @@ +# Frozen N=32 frontier target + +Audit date: 2026-08-06. + +## Claim under test + +Find an exact unscaled 32-point complex DFT circuit with at most 455 charged +real operations. The preserved incumbent uses 456 operations. + +The target uses interleaved real and imaginary inputs and outputs. Addition, +subtraction, and multiplication by a nontrivial real constant each cost one. +Negation and multiplication by `1` or `-1` are free. + +## Primary evidence + +Johnson and Frigo give the split-radix count +`4N log₂N - 6N + 8`. Their modified algorithm first saves operations at +`N=64`. The formula gives 456 operations at `N=32`. + +- Steven G. Johnson and Matteo Frigo, [A Modified Split-Radix FFT With Fewer + Arithmetic Operations](https://fftw.org/newsplit.pdf), IEEE Transactions on + Signal Processing 55(1), 2007. + +Haynal and Haynal report 456 operations for the 32-point tangent FFT, +split-radix FFT, and their SMT solution. They also report that a 455-operation +solution is unsatisfiable within their fixed power-of-two FFT flowgraph when +all twiddles are roots of unity. + +- Steve Haynal and Heidi Haynal, [Generating and Searching Families of FFT + Algorithms](https://arxiv.org/pdf/1103.5740), arXiv:1103.5740v2, Table 1. + +Haynal and Haynal later found 1,136 operations for a weighted 64-point FFT. +That result leaves nontrivial output weights for use inside convolution. It is +not an unscaled DFT and does not change this target. + +- Steve Haynal and Heidi Haynal, [Brute-Force Search of Fast Convolution + Algorithms](http://softerhardware.com/fft/files/fastconvolution03062013.pdf), + ICASSP 2013. + +Alman and Rao improve the asymptotic leading constant by replacing parts of +modified split-radix with faster Walsh-Hadamard transforms. Their reduction +uses transforms of size `N/8` and smaller. The paper does not report a lower +fixed-size count for `N=32`. + +- Josh Alman and Kevin Rao, [Faster Walsh-Hadamard and Discrete Fourier + Transforms From Matrix Non-Rigidity](https://arxiv.org/abs/2211.06459), STOC + 2023. + +## Reproduction + +Generate the incumbent: + +```sh +uv run python -m example.fft_discovery.generate \ + fft32-incumbent.json --n 32 +``` + +Prepare the frozen state: + +```sh +uv run python -m example.fft_discovery.prepare \ + fft32-frontier-state \ + --target example/fft_discovery/targets/n32.json \ + --incumbent fft32-incumbent.json +``` + +The generated circuit passes the trusted exact verifier with 372 additions, +84 nontrivial real multiplications, and 456 total operations. + +## Interpretation + +A verified circuit with at most 455 operations establishes a new upper bound +under this exact circuit and cost model. It also escapes at least one +restriction in the Haynal and Haynal impossibility result. Before publication, +an independent expert must audit the literature scope, verifier, cost model, +and circuit certificate. diff --git a/example/fft_discovery/train.py b/example/fft_discovery/train.py new file mode 100644 index 0000000..7c7bf0e --- /dev/null +++ b/example/fft_discovery/train.py @@ -0,0 +1,632 @@ +"""Run resumable HyTorch generations against an exact FFT verifier.""" + +from __future__ import annotations + +import argparse +import dataclasses +import hashlib +import json +import os +import shutil +import subprocess +import time +from pathlib import Path + +import hytorch +from hytorch._git import GitError +from hytorch.parameter import set_tree_writable + +from .network import FFTDiscoveryNetwork +from .verifier import ( + Target, + Verification, + structural_fingerprint, + verify_circuit, + verify_file, +) + +DEFAULT_MODEL = "gpt-5.6-terra" +DISCOVERY_TASK = """\ +Improve the exact FFT circuit in the statespace. Inspect control/target.json, +CIRCUIT.md, prior reports, archived submissions, and the incumbent. Develop +reusable algebra and executable search programs. Run tools/fft_search.py or +extend it with stronger bounded local synthesis. Every submitted structure must +be new relative to incumbent/ and submissions/archive/. Put this generation's +final candidate JSON files under submissions/current/ with role-specific names. +Run python tools/fft_verify.py on every submitted candidate. Never edit control/ +or incumbent/. A trusted external verifier will ignore changes to those paths. +An unverified numerical approximation or renamed duplicate is not a candidate. +""" +MAX_SUBMISSIONS = 128 +RECOVERABLE_BACKWARD_ERRORS = ("agent produced no final text response",) + + +@dataclasses.dataclass(frozen=True) +class EvaluatedCandidate: + path: str + verification: Verification + structural_sha256: str = "" + duplicate_of: str = "" + + @property + def novel(self) -> bool: + return self.verification.valid and not self.duplicate_of + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("state", nargs="?", help="prepared standalone research state") + parser.add_argument("--run-dir", required=True) + parser.add_argument("--resume", action="store_true") + parser.add_argument("--generations", type=int, default=2) + parser.add_argument("--max-hours", type=float, default=2.0) + parser.add_argument("--max-total-tokens", type=int, default=500_000) + parser.add_argument("--max-stagnant", type=int, default=10) + parser.add_argument("--backward-tokens", type=int, default=4_000) + parser.add_argument("--temp", type=float, default=0.4) + parser.add_argument("--model", default=DEFAULT_MODEL) + parser.add_argument("--provider", default="openai-codex") + parser.add_argument("--allow-calibration", action="store_true") + parser.add_argument("--continue-after-record", action="store_true") + args = parser.parse_args() + if args.generations <= 0: + parser.error("--generations must be positive") + if args.max_hours <= 0: + parser.error("--max-hours must be positive") + if args.max_total_tokens <= 0: + parser.error("--max-total-tokens must be positive") + if args.max_stagnant <= 0: + parser.error("--max-stagnant must be positive") + if args.resume and args.state: + parser.error("omit state when --resume is set") + if not args.resume and not args.state: + parser.error("state is required for a new run") + + run( + state_path=Path(args.state).resolve() if args.state else None, + run_dir=Path(args.run_dir).resolve(), + resume=args.resume, + generations=args.generations, + max_hours=args.max_hours, + max_total_tokens=args.max_total_tokens, + max_stagnant=args.max_stagnant, + backward_tokens=args.backward_tokens, + temp=args.temp, + model_type=args.model, + provider=args.provider, + allow_calibration=args.allow_calibration, + stop_on_record=not args.continue_after_record, + ) + + +def run( + *, + state_path: Path | None, + run_dir: Path, + resume: bool, + generations: int, + max_hours: float, + max_total_tokens: int, + max_stagnant: int, + backward_tokens: int, + temp: float, + model_type: str, + provider: str, + allow_calibration: bool, + stop_on_record: bool, +) -> None: + harness = hytorch.harness.PiHarness(provider=provider) + model = FFTDiscoveryNetwork().to(harness, mtype=model_type) + cumulative_tokens = 0 + prior_elapsed = 0.0 + stagnant = 0 + latest: dict | None = None + + if resume: + latest = _load_latest(run_dir) + generation = latest["generation"] + checkpoint = run_dir / f"generation-{generation:04d}" + model.load_state_dir(hytorch.load(checkpoint / "model")) + state = hytorch.space(checkpoint / "state", harness=harness) + cumulative_tokens = latest["cumulative_tokens"] + prior_elapsed = latest["elapsed_seconds"] + stagnant = latest["stagnant_generations"] + start_generation = generation + 1 + else: + if run_dir.exists(): + raise FileExistsError(run_dir) + state = hytorch.space(state_path, harness=harness) + start_generation = 1 + + target_value = json.loads(state.repo.read_file(state.commit, "control/target.json")) + target = Target.from_dict(target_value) + if target.status == "calibration" and not allow_calibration: + raise RuntimeError( + "refusing to train on a calibration target; pass --allow-calibration " + "for a bounded pipeline test" + ) + canonical_target = json.dumps(target_value, indent=2, sort_keys=True) + "\n" + incumbent_bytes = _read_optional(state, "incumbent/circuit.json") + incumbent_verification = ( + verify_bytes(incumbent_bytes, target) if incumbent_bytes is not None else None + ) + best_total = latest["best_total_operations"] if latest else target.incumbent_total + if not isinstance(best_total, int) or isinstance(best_total, bool): + raise RuntimeError("latest checkpoint has no valid best operation count") + if incumbent_verification is not None: + if not incumbent_verification.valid: + raise RuntimeError( + "stored incumbent circuit does not pass the trusted verifier" + ) + if incumbent_verification.total_operations != best_total: + raise RuntimeError("stored incumbent circuit does not match the best count") + elif best_total < target.incumbent_total: + raise RuntimeError("improved checkpoint has no stored incumbent circuit") + if latest and latest.get("target_beaten") is True and stop_on_record: + print("stop=verified_target_beaten_in_checkpoint", flush=True) + return + if not resume: + run_dir.mkdir(parents=True) + + state = _activate_state(state, harness, run_dir) + + optimizer = hytorch.optim.DFM( + model.parameters(), temp=temp, max_tokens=backward_tokens + ) + if not resume: + _checkpoint( + run_dir, + 0, + model, + state, + { + "generation": 0, + "cumulative_tokens": 0, + "elapsed_seconds": 0.0, + "stagnant_generations": 0, + "best_total_operations": best_total, + "target_beaten": False, + "record_found": False, + "target_status": target.status, + "model": model_type, + "provider": provider, + "mutation_temperature": temp, + "backward_tokens": backward_tokens, + "target_sha256": hashlib.sha256(canonical_target.encode()).hexdigest(), + }, + ) + + started = time.monotonic() + usage_before_run = harness.usage() + final_generation = start_generation + generations - 1 + for generation in range(start_generation, final_generation + 1): + elapsed = prior_elapsed + time.monotonic() - started + if elapsed >= max_hours * 3600: + print("stop=max_hours", flush=True) + break + if cumulative_tokens >= max_total_tokens: + print("stop=max_total_tokens", flush=True) + break + if stagnant >= max_stagnant: + print("stop=max_stagnant", flush=True) + break + + optimizer.zero_feed() + generation_usage_start = harness.usage() + generation_started = time.monotonic() + output = model(state, task=DISCOVERY_TASK) + seen = known_structures(output.dir) + evaluations = evaluate_submissions(output.dir, target, seen) + target_changed = _target_changed(output.dir, canonical_target) + best = best_valid(evaluations) + improved = best is not None and best.verification.total_operations < best_total + feedback = build_feedback( + target, + evaluations, + best, + current_best=best_total, + improved=improved, + target_changed=target_changed, + ) + backward_error = "" + try: + hytorch.Loss(output, feedback=feedback).backward() + optimizer.step() + except RuntimeError as exc: + if not any(message in str(exc) for message in RECOVERABLE_BACKWARD_ERRORS): + raise + backward_error = str(exc) + optimizer.zero_feed() + print( + f"warning=recoverable_backward_error generation={generation}", + flush=True, + ) + + if improved and best is not None: + incumbent_bytes = Path(output.dir, best.path).read_bytes() + incumbent_verification = best.verification + best_total = best.verification.total_operations + stagnant = 0 + else: + stagnant += 1 + + state = promote_generation_state( + output, + harness, + generation, + canonical_target, + evaluations, + incumbent_bytes, + incumbent_verification, + best_total, + backward_error, + ) + generation_usage = harness.usage() - generation_usage_start + run_usage = harness.usage() - usage_before_run + cumulative_tokens_at_start = cumulative_tokens + cumulative_tokens = cumulative_tokens_at_start + _tokens(generation_usage) + elapsed = prior_elapsed + time.monotonic() - started + target_beaten = best_total < target.incumbent_total + record_found = target.status == "frozen" and target_beaten + metadata = { + "generation": generation, + "cumulative_tokens": cumulative_tokens, + "elapsed_seconds": elapsed, + "stagnant_generations": stagnant, + "best_total_operations": best_total, + "target_beaten": target_beaten, + "record_found": record_found, + "target_status": target.status, + "model": model_type, + "provider": provider, + "mutation_temperature": temp, + "backward_tokens": backward_tokens, + "backward_error": backward_error, + "target_sha256": hashlib.sha256(canonical_target.encode()).hexdigest(), + "generation_seconds": time.monotonic() - generation_started, + "generation_usage": dataclasses.asdict(generation_usage), + "current_process_usage": dataclasses.asdict(run_usage), + "valid_submissions": sum( + evaluation.verification.valid for evaluation in evaluations + ), + "novel_submissions": sum(evaluation.novel for evaluation in evaluations), + "duplicate_submissions": sum( + bool(evaluation.duplicate_of) for evaluation in evaluations + ), + "total_submissions": len(evaluations), + } + _checkpoint(run_dir, generation, model, state, metadata) + print( + f"generation={generation} best_total={best_total} " + f"target_beaten={target_beaten} record={record_found} " + f"valid={metadata['valid_submissions']}/" + f"{metadata['total_submissions']} tokens={cumulative_tokens} " + f"seconds={metadata['generation_seconds']:.1f}", + flush=True, + ) + if target_beaten and stop_on_record: + print("stop=verified_target_beaten", flush=True) + break + + +def known_structures(root: str) -> dict[str, str]: + """Return structural identities that predate the current generation.""" + base = Path(root) + paths: list[Path] = [] + incumbent = base / "incumbent/circuit.json" + if incumbent.is_file(): + paths.append(incumbent) + archive = base / "submissions/archive" + if archive.is_dir(): + paths.extend(sorted(archive.rglob("*.json"))) + seen: dict[str, str] = {} + for path in paths: + try: + value = json.loads(path.read_text(encoding="utf-8")) + fingerprint = structural_fingerprint(value) + except (OSError, json.JSONDecodeError, ValueError): + continue + seen.setdefault(fingerprint, path.relative_to(base).as_posix()) + return seen + + +def evaluate_submissions( + root: str, target: Target, seen: dict[str, str] | None = None +) -> list[EvaluatedCandidate]: + current = Path(root, "submissions", "current") + paths = sorted(current.rglob("*.json")) if current.is_dir() else [] + if len(paths) > MAX_SUBMISSIONS: + paths = paths[:MAX_SUBMISSIONS] + known = dict(seen or {}) + evaluations = [] + for path in paths: + relative = path.relative_to(root).as_posix() + verification = verify_file(path, target) + fingerprint = "" + duplicate_of = "" + if verification.valid: + try: + fingerprint = structural_fingerprint(json.loads(path.read_text())) + duplicate_of = known.get(fingerprint, "") + known.setdefault(fingerprint, relative) + except (OSError, json.JSONDecodeError, ValueError): + pass + evaluations.append( + EvaluatedCandidate(relative, verification, fingerprint, duplicate_of) + ) + return evaluations + + +def best_valid( + evaluations: list[EvaluatedCandidate], +) -> EvaluatedCandidate | None: + valid = [value for value in evaluations if value.novel] + return min(valid, key=lambda value: value.verification.score) if valid else None + + +def build_feedback( + target: Target, + evaluations: list[EvaluatedCandidate], + best: EvaluatedCandidate | None, + *, + current_best: int, + improved: bool, + target_changed: bool, +) -> str: + valid = [value for value in evaluations if value.verification.valid] + invalid = [value for value in evaluations if not value.verification.valid] + novel = [value for value in evaluations if value.novel] + duplicates = [value for value in evaluations if value.duplicate_of] + lines = [ + f"Trusted exact evaluation for N={target.n}: {len(valid)} of " + f"{len(evaluations)} submissions are valid.", + f"Structural novelty: {len(novel)} new and {len(duplicates)} duplicate.", + f"The declared incumbent uses {target.incumbent_total} real arithmetic operations.", + ] + if current_best != target.incumbent_total: + lines.append( + f"The best preserved circuit now uses {current_best} real arithmetic " + "operations." + ) + if target_changed: + lines.append( + "The forward state changed control/target.json. Do not edit trusted control files." + ) + if best is not None: + score = best.verification + lines.append( + f"Best valid submission {best.path} uses {score.total_operations} total " + f"operations: {score.additions} additions and {score.multiplications} " + f"multiplications, with depth {score.depth}." + ) + else: + lines.append("No new valid candidate was submitted.") + if improved: + lines.append( + "The best candidate is an exact improvement. Preserve the reusable methods " + "that produced it and attempt independent simplification and validation." + ) + else: + lines.append( + "No candidate improved the incumbent. Improve the reusable search, algebra, " + "and simplification procedures. Submit fewer and stronger exact candidates." + ) + for evaluation in invalid[:8]: + lines.append(f"Invalid {evaluation.path}: {evaluation.verification.error}") + for evaluation in duplicates[:8]: + lines.append( + f"Duplicate {evaluation.path}: same structure as {evaluation.duplicate_of}." + ) + return "\n".join(lines) + + +def promote_generation_state( + output: hytorch.Space, + harness: hytorch.harness.Harness, + generation: int, + canonical_target: str, + evaluations: list[EvaluatedCandidate], + incumbent_bytes: bytes | None, + incumbent_verification: Verification | None, + best_total: int, + backward_error: str, +) -> hytorch.Space: + root = Path(output.dir) + set_tree_writable(output.dir, True) + shutil.rmtree(root / "control", ignore_errors=True) + Path(root, "control").mkdir() + Path(root, "control/target.json").write_text(canonical_target, encoding="utf-8") + Path(root, "tools").mkdir(exist_ok=True) + shutil.copy2(Path(__file__).with_name("verifier.py"), root / "tools/fft_verify.py") + shutil.copy2(Path(__file__).with_name("search.py"), root / "tools/fft_search.py") + + shutil.rmtree(root / "incumbent", ignore_errors=True) + Path(root, "incumbent").mkdir() + if incumbent_bytes is not None: + Path(root, "incumbent/circuit.json").write_bytes(incumbent_bytes) + if incumbent_verification is not None: + _write_json( + root / "incumbent/verification.json", + dataclasses.asdict(incumbent_verification), + ) + + current = root / "submissions/current" + archive = root / f"submissions/archive/generation-{generation:04d}" + if current.exists(): + archive.parent.mkdir(parents=True, exist_ok=True) + if archive.exists(): + raise RuntimeError(f"submission archive already exists: {archive}") + shutil.move(current, archive) + current.mkdir(parents=True) + Path(current, "README.md").write_text( + "# Current submissions\n\nWrite this generation's candidate JSON files here.\n", + encoding="utf-8", + ) + _write_json( + root / f"reports/generation-{generation:04d}.json", + { + "generation": generation, + "best_total_operations": best_total, + "backward_error": backward_error, + "submissions": [ + { + "path": value.path, + "structural_sha256": value.structural_sha256, + "duplicate_of": value.duplicate_of, + "novel": value.novel, + "verification": dataclasses.asdict(value.verification), + } + for value in evaluations + ], + }, + ) + _git(root, "add", "-A") + _git(root, "commit", "-m", f"Evaluate FFT generation {generation}") + return hytorch.space(root, harness=harness) + + +def verify_bytes(value: bytes, target: Target) -> Verification: + try: + result = verify_circuit(json.loads(value), target) + return dataclasses.replace( + result, candidate_sha256=hashlib.sha256(value).hexdigest() + ) + except (json.JSONDecodeError, ValueError) as exc: + return Verification(valid=False, error=str(exc)) + + +def _checkpoint( + run_dir: Path, + generation: int, + model: FFTDiscoveryNetwork, + state: hytorch.Space, + metadata: dict, +) -> None: + destination = run_dir / f"generation-{generation:04d}" + if destination.exists(): + raise FileExistsError(destination) + temporary_checkpoint = run_dir / f".generation-{generation:04d}.tmp" + shutil.rmtree(temporary_checkpoint, ignore_errors=True) + temporary_checkpoint.mkdir() + hytorch.save(model.state_dir(), temporary_checkpoint / "model") + _clone_state(state, temporary_checkpoint / "state") + _write_json(temporary_checkpoint / "metadata.json", metadata) + os.replace(temporary_checkpoint, destination) + temporary = run_dir / ".latest.json" + _write_json(temporary, metadata) + os.replace(temporary, run_dir / "latest.json") + + +def _clone_state(state: hytorch.Space, destination: Path) -> None: + _git( + state.repo.root, + "clone", + "--quiet", + "--no-local", + "--no-checkout", + "--no-tags", + state.repo.root, + str(destination), + ) + _git(destination, "checkout", "--quiet", "-B", "main", state.commit) + _git(destination, "remote", "remove", "origin") + + +def _activate_state( + state: hytorch.Space, harness: hytorch.harness.Harness, run_dir: Path +) -> hytorch.Space: + """Clone input state and install the current trusted executable tools.""" + destination = run_dir / ".active-state" + shutil.rmtree(destination, ignore_errors=True) + _clone_state(state, destination) + set_tree_writable(destination, True) + tools = destination / "tools" + tools.mkdir(exist_ok=True) + shutil.copy2(Path(__file__).with_name("verifier.py"), tools / "fft_verify.py") + shutil.copy2(Path(__file__).with_name("search.py"), tools / "fft_search.py") + _git(destination, "add", "tools/fft_verify.py", "tools/fft_search.py") + if _git(destination, "status", "--porcelain"): + _git(destination, "commit", "-m", "Install trusted FFT search tools") + return hytorch.space(destination, harness=harness) + + +def _load_latest(run_dir: Path) -> dict: + if not run_dir.is_dir(): + raise FileNotFoundError(run_dir) + try: + value = json.loads(Path(run_dir, "latest.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError("run directory has no valid latest checkpoint") from exc + integer_fields = { + "generation": 0, + "cumulative_tokens": 0, + "stagnant_generations": 0, + "best_total_operations": 1, + } + for field, minimum in integer_fields.items(): + item = value.get(field) + if not isinstance(item, int) or isinstance(item, bool) or item < minimum: + raise RuntimeError(f"latest checkpoint has invalid {field}") + elapsed = value.get("elapsed_seconds") + if ( + not isinstance(elapsed, (int, float)) + or isinstance(elapsed, bool) + or elapsed < 0 + ): + raise RuntimeError("latest checkpoint has invalid elapsed_seconds") + for field in ("record_found", "target_beaten"): + if not isinstance(value.get(field), bool): + raise RuntimeError(f"latest checkpoint has invalid {field}") + return value + + +def _read_optional(state: hytorch.Space, path: str) -> bytes | None: + try: + return state.repo.read_file(state.commit, path) + except GitError: + return None + + +def _target_changed(root: str, canonical_target: str) -> bool: + try: + return ( + Path(root, "control/target.json").read_text(encoding="utf-8") + != canonical_target + ) + except OSError: + return True + + +def _tokens(usage: hytorch.harness.Usage) -> int: + return usage.input_tokens + usage.output_tokens + + +def _write_json(path: Path, value) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + + +def _git(root: str | Path, *args: str) -> str: + env = dict(os.environ) + env.update( + GIT_AUTHOR_NAME="HyTorch", + GIT_AUTHOR_EMAIL="hytorch@localhost", + GIT_COMMITTER_NAME="HyTorch", + GIT_COMMITTER_EMAIL="hytorch@localhost", + ) + result = subprocess.run( + ["git", "-C", root, *args], + env=env, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or f"git {' '.join(args)} failed") + return result.stdout.strip() + + +if __name__ == "__main__": + main() diff --git a/example/fft_discovery/verifier.py b/example/fft_discovery/verifier.py new file mode 100644 index 0000000..392f091 --- /dev/null +++ b/example/fft_discovery/verifier.py @@ -0,0 +1,627 @@ +"""Exact verifier for real-arithmetic circuits computing a complex DFT.""" + +from __future__ import annotations + +import argparse +import dataclasses +import hashlib +import json +from fractions import Fraction +from pathlib import Path +from typing import Any + +TARGET_FORMAT = "hytorch-fft-target-v1" +CIRCUIT_FORMAT = "hytorch-fft-circuit-v1" +MAX_FILE_BYTES = 8_000_000 +MAX_RATIONAL_CHARS = 256 +TRANSFORM = "unscaled complex-input DFT with negative exponential sign" +INPUT_ORDER = "x0.real, x0.imag, x1.real, x1.imag, ..." +OUTPUT_ORDER = "X0.real, X0.imag, X1.real, X1.imag, ..." +COST_MODEL = { + "add": 1, + "sub": 1, + "nontrivial_real_scale": 1, + "negation": 0, + "multiplication_by_one_or_minus_one": 0, + "fused_operations": "not allowed", +} + + +class VerificationError(ValueError): + """A target or candidate does not satisfy the declared format.""" + + +@dataclasses.dataclass(frozen=True) +class Target: + n: int + status: str + incumbent_total: int + incumbent_name: str + source: str + max_operations: int + + @classmethod + def load(cls, path: str | Path) -> Target: + return cls.from_dict(_load_json(path)) + + @classmethod + def from_dict(cls, value: Any) -> Target: + if not isinstance(value, dict) or value.get("format") != TARGET_FORMAT: + raise VerificationError(f"target format must be {TARGET_FORMAT!r}") + if value.get("transform") != TRANSFORM: + raise VerificationError(f"target transform must be {TRANSFORM!r}") + if value.get("input_order") != INPUT_ORDER: + raise VerificationError(f"target input_order must be {INPUT_ORDER!r}") + if value.get("output_order") != OUTPUT_ORDER: + raise VerificationError(f"target output_order must be {OUTPUT_ORDER!r}") + if value.get("cost_model") != COST_MODEL: + raise VerificationError("target cost_model does not match the verifier") + n = value.get("n") + status = value.get("status") + incumbent = value.get("incumbent") + limits = value.get("limits", {}) + if not _is_power_of_two(n) or n < 4: + raise VerificationError("target n must be a power of two of at least 4") + if status not in {"calibration", "frozen"}: + raise VerificationError("target status must be 'calibration' or 'frozen'") + if not isinstance(incumbent, dict): + raise VerificationError("target incumbent must be an object") + total = incumbent.get("total_operations") + name = incumbent.get("name") + source = incumbent.get("source") + maximum = limits.get("max_operations", max(10_000, total * 4 if total else 0)) + if not isinstance(total, int) or isinstance(total, bool) or total <= 0: + raise VerificationError("incumbent total_operations must be positive") + if not isinstance(name, str) or not name.strip(): + raise VerificationError("incumbent name must be non-empty text") + if not isinstance(source, str) or not source.strip(): + raise VerificationError("incumbent source must be non-empty text") + if not isinstance(maximum, int) or isinstance(maximum, bool) or maximum <= 0: + raise VerificationError("limits.max_operations must be positive") + return cls(n, status, total, name.strip(), source.strip(), maximum) + + +@dataclasses.dataclass(frozen=True) +class Verification: + valid: bool + additions: int = 0 + multiplications: int = 0 + total_operations: int = 0 + depth: int = 0 + live_operations: int = 0 + dead_operations: int = 0 + candidate_sha256: str = "" + error: str = "" + + @property + def score(self) -> tuple[int, int, int]: + return (self.total_operations, self.depth, self.multiplications) + + +class Cyclotomic: + """Exact arithmetic in Q(ω), where ω is a power-of-two root of unity.""" + + def __init__(self, n: int): + if not _is_power_of_two(n) or n < 4: + raise ValueError("cyclotomic order must be a power of two of at least 4") + self.n = n + self.degree = n // 2 + self.zero = (Fraction(0),) * self.degree + self.one = self.rational(1) + self.minus_one = self.rational(-1) + + def rational(self, value: int | Fraction) -> tuple[Fraction, ...]: + return (Fraction(value),) + (Fraction(0),) * (self.degree - 1) + + def root(self, exponent: int) -> tuple[Fraction, ...]: + exponent %= self.n + sign = 1 + if exponent >= self.degree: + exponent -= self.degree + sign = -1 + values = [Fraction(0)] * self.degree + values[exponent] = Fraction(sign) + return tuple(values) + + def add(self, left, right): + return tuple(a + b for a, b in zip(left, right, strict=True)) + + def sub(self, left, right): + return tuple(a - b for a, b in zip(left, right, strict=True)) + + def neg(self, value): + return tuple(-item for item in value) + + def scale_rational(self, value, scalar: int | Fraction): + scalar = Fraction(scalar) + return tuple(scalar * item for item in value) + + def mul(self, left, right): + result = [Fraction(0)] * self.degree + left_terms = [(i, value) for i, value in enumerate(left) if value] + right_terms = [(i, value) for i, value in enumerate(right) if value] + for i, a in left_terms: + for j, b in right_terms: + exponent = i + j + if exponent >= self.degree: + result[exponent - self.degree] -= a * b + else: + result[exponent] += a * b + return tuple(result) + + def conjugate(self, value): + result = self.zero + for exponent, coefficient in enumerate(value): + if coefficient: + result = self.add( + result, + self.scale_rational(self.root(-exponent), coefficient), + ) + return result + + def is_real(self, value) -> bool: + return self.conjugate(value) == value + + def real_part(self, value): + return self.scale_rational( + self.add(value, self.conjugate(value)), Fraction(1, 2) + ) + + def imaginary_part(self, value): + # ω = exp(-2πi/N), so q = ω^(N/4) = -i and + # Im(z) = q(z - conjugate(z))/2. + difference = self.sub(value, self.conjugate(value)) + return self.scale_rational( + self.mul(self.root(self.n // 4), difference), Fraction(1, 2) + ) + + def parse(self, value: Any): + if not isinstance(value, dict) or set(value) != {"basis"}: + raise VerificationError("scale constant must contain one 'basis' object") + basis = value["basis"] + if not isinstance(basis, dict): + raise VerificationError("constant basis must be an object") + result = [Fraction(0)] * self.degree + for raw_exponent, raw_coefficient in basis.items(): + try: + exponent = int(raw_exponent) + except (TypeError, ValueError) as exc: + raise VerificationError("constant exponent must be an integer") from exc + if str(exponent) != str(raw_exponent) or not 0 <= exponent < self.degree: + raise VerificationError( + f"constant exponent must be between 0 and {self.degree - 1}" + ) + try: + if isinstance(raw_coefficient, bool) or not isinstance( + raw_coefficient, (int, str) + ): + raise TypeError + if len(str(raw_coefficient)) > MAX_RATIONAL_CHARS: + raise VerificationError("constant coefficient is too long") + coefficient = Fraction(raw_coefficient) + except (TypeError, ValueError, ZeroDivisionError) as exc: + raise VerificationError( + "constant coefficient must be an integer or rational string" + ) from exc + result[exponent] += coefficient + return tuple(result) + + def encode(self, value) -> dict[str, dict[str, str]]: + return { + "basis": { + str(index): str(coefficient) + for index, coefficient in enumerate(value) + if coefficient + } + } + + def display(self, value) -> str: + terms = [] + for index, coefficient in enumerate(value): + if coefficient: + suffix = "" if index == 0 else f"*omega^{index}" + terms.append(f"{coefficient}{suffix}") + return " + ".join(terms) if terms else "0" + + +def verify_file(path: str | Path, target: Target) -> Verification: + candidate_path = Path(path) + try: + raw = candidate_path.read_bytes() + if len(raw) > MAX_FILE_BYTES: + raise VerificationError("candidate file is too large") + value = json.loads(raw) + result = verify_circuit(value, target) + return dataclasses.replace( + result, candidate_sha256=hashlib.sha256(raw).hexdigest() + ) + except (OSError, json.JSONDecodeError, VerificationError) as exc: + return Verification(valid=False, error=str(exc)) + + +def structural_fingerprint(candidate: Any) -> str: + """Return an identity for circuit structure, independent of prose metadata.""" + if not isinstance(candidate, dict): + raise VerificationError("candidate must be an object") + required = ("format", "n", "operations", "outputs") + if any(name not in candidate for name in required): + raise VerificationError("candidate has no complete circuit structure") + structure = {name: candidate[name] for name in required} + canonical = json.dumps( + structure, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ) + return hashlib.sha256(canonical.encode()).hexdigest() + + +def verify_circuit(candidate: Any, target: Target) -> Verification: + if not isinstance(candidate, dict) or candidate.get("format") != CIRCUIT_FORMAT: + raise VerificationError(f"candidate format must be {CIRCUIT_FORMAT!r}") + if candidate.get("n") != target.n: + raise VerificationError(f"candidate n must equal target n={target.n}") + operations = candidate.get("operations") + outputs = candidate.get("outputs") + if not isinstance(operations, list): + raise VerificationError("candidate operations must be a list") + if len(operations) > target.max_operations: + raise VerificationError( + f"candidate has {len(operations)} operations; limit is {target.max_operations}" + ) + if not isinstance(outputs, list) or len(outputs) != 2 * target.n: + raise VerificationError( + f"candidate outputs must contain {2 * target.n} registers" + ) + + field = Cyclotomic(target.n) + input_count = 2 * target.n + forms: list[dict[int, tuple[Fraction, ...]]] = [ + {index: field.one} for index in range(input_count) + ] + depths = [0] * input_count + sources: list[tuple[int, ...]] = [] + costs: list[tuple[int, int]] = [] + + for operation_index, operation in enumerate(operations): + if not isinstance(operation, dict): + raise VerificationError(f"operation {operation_index} must be an object") + kind = operation.get("op") + register_count = len(forms) + if kind in {"add", "sub"}: + if set(operation) != {"op", "a", "b"}: + raise VerificationError( + f"operation {operation_index} has invalid fields" + ) + a = _register(operation["a"], register_count, operation_index) + b = _register(operation["b"], register_count, operation_index) + form = _combine_forms(field, forms[a], forms[b], subtract=kind == "sub") + depth = max(depths[a], depths[b]) + 1 + sources.append((a, b)) + costs.append((1, 0)) + elif kind == "neg": + if set(operation) != {"op", "a"}: + raise VerificationError( + f"operation {operation_index} has invalid fields" + ) + a = _register(operation["a"], register_count, operation_index) + form = {index: field.neg(value) for index, value in forms[a].items()} + depth = depths[a] + sources.append((a,)) + costs.append((0, 0)) + elif kind == "scale": + if set(operation) != {"op", "a", "constant"}: + raise VerificationError( + f"operation {operation_index} has invalid fields" + ) + a = _register(operation["a"], register_count, operation_index) + constant = field.parse(operation["constant"]) + if not field.is_real(constant): + raise VerificationError( + f"operation {operation_index} scale constant is not real" + ) + form = { + index: product + for index, value in forms[a].items() + if (product := field.mul(constant, value)) != field.zero + } + depth = depths[a] + (constant not in {field.one, field.minus_one}) + sources.append((a,)) + costs.append((0, int(constant not in {field.one, field.minus_one}))) + else: + raise VerificationError( + f"operation {operation_index} has invalid op {kind!r}" + ) + forms.append(form) + depths.append(depth) + + output_registers = [ + _register(value, len(forms), f"output {index}") + for index, value in enumerate(outputs) + ] + expected = _expected_dft(field) + for output_index, (register, expected_form) in enumerate( + zip(output_registers, expected, strict=True) + ): + actual = forms[register] + variables = sorted(set(actual) | set(expected_form)) + for variable in variables: + actual_value = actual.get(variable, field.zero) + expected_value = expected_form.get(variable, field.zero) + if actual_value != expected_value: + raise VerificationError( + f"output {output_index} coefficient for input {variable} differs: " + f"got {field.display(actual_value)}, expected " + f"{field.display(expected_value)}" + ) + + live = _live_operations(input_count, sources, output_registers) + additions = sum(costs[index][0] for index in live) + multiplications = sum(costs[index][1] for index in live) + total = additions + multiplications + return Verification( + valid=True, + additions=additions, + multiplications=multiplications, + total_operations=total, + depth=max(depths[register] for register in output_registers), + live_operations=len(live), + dead_operations=len(operations) - len(live), + ) + + +def direct_dft_circuit(n: int) -> dict[str, Any]: + """Return a correct direct DFT circuit for calibration and verifier tests.""" + field = Cyclotomic(n) + operations: list[dict[str, Any]] = [] + input_count = 2 * n + + def emit(kind: str, **values) -> int: + operations.append({"op": kind, **values}) + return input_count + len(operations) - 1 + + def scaled(register: int, constant) -> int | None: + if constant == field.zero: + return None + if constant == field.one: + return register + if constant == field.minus_one: + return emit("neg", a=register) + return emit("scale", a=register, constant=field.encode(constant)) + + def sum_terms(terms: list[int]) -> int: + if not terms: + raise RuntimeError("direct DFT output unexpectedly has no terms") + result = terms[0] + for term in terms[1:]: + result = emit("add", a=result, b=term) + return result + + outputs = [] + for expected_form in _expected_dft(field): + terms = [] + for variable, coefficient in expected_form.items(): + term = scaled(variable, coefficient) + if term is not None: + terms.append(term) + outputs.append(sum_terms(terms)) + return { + "format": CIRCUIT_FORMAT, + "n": n, + "description": "Direct exact DFT used as a calibration incumbent.", + "operations": operations, + "outputs": outputs, + } + + +def split_radix_circuit(n: int) -> dict[str, Any]: + """Return a conjugate-pair-cost split-radix circuit for a power-of-two DFT.""" + field = Cyclotomic(n) + operations: list[dict[str, Any]] = [] + input_count = 2 * n + + def emit(kind: str, **values) -> int: + operations.append({"op": kind, **values}) + return input_count + len(operations) - 1 + + def add(left: int, right: int) -> int: + return emit("add", a=left, b=right) + + def sub(left: int, right: int) -> int: + return emit("sub", a=left, b=right) + + def neg(register: int) -> int: + return emit("neg", a=register) + + def scale(register: int, constant) -> int | None: + if constant == field.zero: + return None + if constant == field.one: + return register + if constant == field.minus_one: + return neg(register) + return emit("scale", a=register, constant=field.encode(constant)) + + def combine_terms(terms: list[tuple[int, int | None]]) -> int: + nonzero = [(sign, register) for sign, register in terms if register is not None] + if not nonzero: + raise RuntimeError("split-radix product unexpectedly has no terms") + sign, register = nonzero[0] + result = register if sign == 1 else neg(register) + for sign, register in nonzero[1:]: + result = add(result, register) if sign == 1 else sub(result, register) + return result + + def multiply(pair: tuple[int, int], exponent: int) -> tuple[int, int]: + twiddle = field.root(exponent) + cosine = field.real_part(twiddle) + sine = field.imaginary_part(twiddle) + real, imaginary = pair + if cosine == sine and cosine != field.zero: + return ( + scale(sub(real, imaginary), cosine), + scale(add(real, imaginary), cosine), + ) + if cosine == field.neg(sine) and cosine != field.zero: + return ( + scale(add(real, imaginary), cosine), + scale(sub(imaginary, real), cosine), + ) + return ( + combine_terms([(1, scale(real, cosine)), (-1, scale(imaginary, sine))]), + combine_terms([(1, scale(real, sine)), (1, scale(imaginary, cosine))]), + ) + + def transform(indices: list[int]) -> list[tuple[int, int]]: + size = len(indices) + if size == 1: + index = indices[0] + return [(2 * index, 2 * index + 1)] + if size == 2: + first = (2 * indices[0], 2 * indices[0] + 1) + second = (2 * indices[1], 2 * indices[1] + 1) + return [ + (add(first[0], second[0]), add(first[1], second[1])), + (sub(first[0], second[0]), sub(first[1], second[1])), + ] + + even = transform(indices[0::2]) + odd_one = transform(indices[1::4]) + odd_three = transform(indices[3::4]) + quarter = size // 4 + outputs: list[tuple[int, int] | None] = [None] * size + root_stride = n // size + for k in range(quarter): + first = multiply(odd_one[k], k * root_stride) + third = multiply(odd_three[k], 3 * k * root_stride) + pair_sum = (add(first[0], third[0]), add(first[1], third[1])) + pair_difference = ( + sub(first[0], third[0]), + sub(first[1], third[1]), + ) + low = even[k] + high = even[k + quarter] + outputs[k] = ( + add(low[0], pair_sum[0]), + add(low[1], pair_sum[1]), + ) + outputs[k + size // 2] = ( + sub(low[0], pair_sum[0]), + sub(low[1], pair_sum[1]), + ) + outputs[k + quarter] = ( + add(high[0], pair_difference[1]), + sub(high[1], pair_difference[0]), + ) + outputs[k + 3 * quarter] = ( + sub(high[0], pair_difference[1]), + add(high[1], pair_difference[0]), + ) + if any(value is None for value in outputs): + raise RuntimeError("split-radix circuit left an output unset") + return [value for value in outputs if value is not None] + + outputs = [register for pair in transform(list(range(n))) for register in pair] + return { + "format": CIRCUIT_FORMAT, + "n": n, + "description": "Exact split-radix DFT incumbent.", + "operations": operations, + "outputs": outputs, + } + + +def _expected_dft(field: Cyclotomic): + expected: list[dict[int, tuple[Fraction, ...]]] = [] + for output_index in range(field.n): + real_form = {} + imaginary_form = {} + for input_index in range(field.n): + twiddle = field.root(output_index * input_index) + cosine = field.real_part(twiddle) + sine = field.imaginary_part(twiddle) + real_input = 2 * input_index + imaginary_input = real_input + 1 + if cosine != field.zero: + real_form[real_input] = cosine + imaginary_form[imaginary_input] = cosine + if sine != field.zero: + real_form[imaginary_input] = field.neg(sine) + imaginary_form[real_input] = sine + expected.extend((real_form, imaginary_form)) + return expected + + +def _combine_forms(field, left, right, *, subtract: bool): + result = dict(left) + for index, value in right.items(): + combined = ( + field.sub(result.get(index, field.zero), value) + if subtract + else field.add(result.get(index, field.zero), value) + ) + if combined == field.zero: + result.pop(index, None) + else: + result[index] = combined + return result + + +def _live_operations( + input_count: int, sources: list[tuple[int, ...]], outputs: list[int] +) -> set[int]: + live: set[int] = set() + stack = list(outputs) + while stack: + register = stack.pop() + if register < input_count: + continue + operation = register - input_count + if operation in live: + continue + live.add(operation) + stack.extend(sources[operation]) + return live + + +def _register(value: Any, limit: int, location: int | str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value < limit: + raise VerificationError(f"{location} references invalid register {value!r}") + return value + + +def _load_json(path: str | Path) -> Any: + try: + raw = Path(path).read_bytes() + except OSError as exc: + raise VerificationError(str(exc)) from exc + if len(raw) > MAX_FILE_BYTES: + raise VerificationError("JSON file is too large") + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise VerificationError(str(exc)) from exc + + +def _is_power_of_two(value: Any) -> bool: + return ( + isinstance(value, int) + and not isinstance(value, bool) + and value > 0 + and value & (value - 1) == 0 + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("target") + parser.add_argument("candidate") + args = parser.parse_args() + target = Target.load(args.target) + result = verify_file(args.candidate, target) + print(json.dumps(dataclasses.asdict(result), indent=2, sort_keys=True)) + raise SystemExit(0 if result.valid else 1) + + +if __name__ == "__main__": + main() diff --git a/hytorch/_autofeed.py b/hytorch/_autofeed.py index f6c0c20..12a236f 100644 --- a/hytorch/_autofeed.py +++ b/hytorch/_autofeed.py @@ -28,6 +28,7 @@ class Node: commit: str root: str workspace: str + parameter: str statespace: str workspace_revision: str consumed: bool = False diff --git a/hytorch/_environment.py b/hytorch/_environment.py index 90b8e9c..5dc72eb 100644 --- a/hytorch/_environment.py +++ b/hytorch/_environment.py @@ -15,7 +15,32 @@ PROJECT_ENV = ".hytorch.env" GLOBAL_ENV = os.path.join("hytorch", "secrets.env") EXPLICIT_ENV = "HYTORCH_ENV_FILE" -KNOWN_PROVIDER_KEYS = ("OPENAI_API_KEY",) +KNOWN_PROVIDER_KEYS = ( + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + "CODEX_API_KEY", + "DEEPSEEK_API_KEY", + "GOOGLE_API_KEY", + "GROQ_API_KEY", + "NOUS_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "TOGETHER_API_KEY", +) +RUNTIME_ENV_KEYS = ( + "HTTP_PROXY", + "HTTPS_PROXY", + "LANG", + "LC_ALL", + "NODE_EXTRA_CA_CERTS", + "NO_PROXY", + "PATH", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "TEMP", + "TMP", + "TMPDIR", +) _NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _warned_tracked: set[str] = set() _warning_lock = threading.Lock() @@ -68,6 +93,19 @@ def agent_environment(start: str | None = None) -> dict[str, str]: return values +def command_environment( + start: str | None = None, *, values: Mapping[str, str] | None = None +) -> dict[str, str]: + """Build a minimal process environment plus declared agent values.""" + environment = { + name: os.environ[name] for name in RUNTIME_ENV_KEYS if name in os.environ + } + environment.update(agent_environment(start)) + if values is not None: + environment.update(values) + return environment + + @contextlib.contextmanager def docker_environment_file( start: str | None = None, *, values: Mapping[str, str] | None = None @@ -160,6 +198,7 @@ def _warn_if_tracked(path: str, root: str, project_file: str) -> None: "KNOWN_PROVIDER_KEYS", "PROJECT_ENV", "agent_environment", + "command_environment", "docker_environment_file", "environment_files", "project_root", diff --git a/hytorch/_native_view.py b/hytorch/_native_view.py new file mode 100644 index 0000000..8a53aed --- /dev/null +++ b/hytorch/_native_view.py @@ -0,0 +1,113 @@ +"""Stable local working-directory views for persistent native sessions.""" + +from __future__ import annotations + +import contextlib +import os +import shutil +import tempfile +import threading +import uuid +from collections.abc import Iterator + +try: + import fcntl +except ImportError: # pragma: no cover - Windows uses the process-local lock. + fcntl = None + +_locks_guard = threading.Lock() +_locks: dict[str, threading.Lock] = {} + + +@contextlib.contextmanager +def native_node_view(directory: str) -> Iterator[str]: + """Expose one changing node workspace at one stable local path.""" + root = os.path.realpath(directory) + statespace = os.path.join(root, "statespace") + workspace = os.path.join(root, "workspace") + if not os.path.isdir(statespace) or not os.path.isdir(workspace): + raise RuntimeError("hytorch harness: node state is unavailable") + + identity = _node_identity(workspace) + with _locks_guard: + lock = _locks.setdefault(identity, threading.Lock()) + with lock, _process_lock(identity): + view = _view_path(identity) + os.makedirs(view, mode=0o700, exist_ok=True) + targets = [("statespace", statespace), ("workspace", workspace)] + parameter = os.path.join(root, "parameter") + if os.path.isdir(parameter): + targets.append(("parameter", parameter)) + for name, target in targets: + link = os.path.join(view, name) + if os.path.lexists(link): + if os.path.isdir(link) and not os.path.islink(link): + shutil.rmtree(link) + else: + os.remove(link) + os.symlink(target, link, target_is_directory=True) + failed = False + try: + yield view + except BaseException: + failed = True + raise + finally: + for name, _ in targets: + link = os.path.join(view, name) + if os.path.lexists(link): + os.remove(link) + try: + os.rmdir(view) + except OSError: + pass + if not failed and _read_identity(workspace) != identity: + raise RuntimeError( + "hytorch harness: agent changed its native node identity" + ) + + +def _node_identity(workspace: str) -> str: + metadata = os.path.join(workspace, ".hytorch") + path = os.path.join(metadata, "node-id") + if os.path.isfile(path): + return _read_identity(workspace) + os.makedirs(metadata, exist_ok=True) + value = uuid.uuid4().hex + with open(path, "w", encoding="ascii") as file: + file.write(value + "\n") + return value + + +def _read_identity(workspace: str) -> str: + path = os.path.join(workspace, ".hytorch", "node-id") + if not os.path.isfile(path): + raise RuntimeError("hytorch harness: native node identity is unavailable") + with open(path, encoding="ascii") as file: + value = file.read().strip() + try: + return uuid.UUID(value).hex + except ValueError as exc: + raise RuntimeError("hytorch harness: native node identity is invalid") from exc + + +def _view_path(identity: str) -> str: + return os.path.join(tempfile.gettempdir(), "hytorch-native", identity) + + +@contextlib.contextmanager +def _process_lock(identity: str) -> Iterator[None]: + root = os.path.join(tempfile.gettempdir(), "hytorch-native") + os.makedirs(root, mode=0o700, exist_ok=True) + path = os.path.join(root, identity + ".lock") + with open(path, "a", encoding="ascii") as file: + if fcntl is not None: + fcntl.flock(file.fileno(), fcntl.LOCK_EX) + try: + yield + finally: + if fcntl is not None: + fcntl.flock(file.fileno(), fcntl.LOCK_UN) + + +__all__ = ["native_node_view"] diff --git a/hytorch/backward.py b/hytorch/backward.py index 30e2254..b36723a 100644 --- a/hytorch/backward.py +++ b/hytorch/backward.py @@ -22,13 +22,15 @@ def __post_init__(self) -> None: raise ValueError("hytorch.Loss feedback must be non-empty text") self.feedback = self.feedback.strip() - def backward(self) -> None: - """Update candidate workspaces and propagate feedback through the graph.""" + def backward(self, retain_graph: bool | None = None) -> None: + """Accumulate directional feedback through the executed graph.""" + if retain_graph is not None and not isinstance(retain_graph, bool): + raise TypeError("hytorch.Loss.backward retain_graph must be a bool or None") if self.output.feed_fn is None: raise RuntimeError("hytorch.Loss output has no executed graph to traverse") nodes = ancestors([self.output.feed_fn]) for node in nodes: - if node.released or node.consumed: + if node.consumed: raise RuntimeError( "Trying to backward through the graph a second time. " "Run a new forward pass first." @@ -48,7 +50,7 @@ def backward(self) -> None: "hytorch.Loss.backward requires one optimizer for the executed graph" ) optimizer = next(iter(optimizers)) - optimizer._backward(self.output, self.feedback) + optimizer._backward(self.output, self.feedback, retain_graph=bool(retain_graph)) @dataclasses.dataclass(frozen=True) diff --git a/hytorch/claude_harness.py b/hytorch/claude_harness.py new file mode 100644 index 0000000..85f88a8 --- /dev/null +++ b/hytorch/claude_harness.py @@ -0,0 +1,245 @@ +"""Local Claude Code harness with node-local, persistent native state.""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +import threading +from collections.abc import Callable, Mapping +from contextlib import contextmanager +from pathlib import Path + +from ._environment import command_environment +from ._native_view import native_node_view +from .harness import Harness, Result, Session, Usage + +_DEFAULT_MODEL = "sonnet" +_UUID = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) + + +class ClaudeCodeHarness(Harness): + """Run one persistent Claude Code session from each node workspace.""" + + name = "claude-code" + + def __init__( + self, + name: str | None = None, + *, + model: str = _DEFAULT_MODEL, + binary: str = "claude", + environment: Mapping[str, str] | None = None, + auth_file: str | None = None, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, + ) -> None: + super().__init__(name) + self.model = model + self.binary = binary + self.environment = dict(environment or {}) + self.auth_file = auth_file + self._runner = runner + self._usage = Usage() + self._usage_lock = threading.Lock() + + def start( + self, + directory: str, + prompt: str, + mtype: str | None, + *, + temperature: float | None = None, + max_tokens: int | None = None, + read_only: tuple[str, ...] = (), + ) -> Result: + """Start a node session, or continue the session in its workspace state.""" + self._validate_sampling(temperature, max_tokens) + home, workspace = self._paths(directory) + command = self._command(mtype) + prior = _latest_claude_session(home) + if prior is not None: + command.extend(["--resume", prior]) + with native_node_view(directory) as project: + return self._invoke(command, home, workspace, Path(project), prompt) + + def resume( + self, + session: Session, + directory: str, + prompt: str, + mtype: str | None, + *, + temperature: float | None = None, + max_tokens: int | None = None, + read_only: tuple[str, ...] = (), + ) -> Result: + """Append one turn to the node's current Claude Code session.""" + self._validate_session(session) + self._validate_sampling(temperature, max_tokens) + home, workspace = self._paths(directory) + command = self._command(mtype) + command.extend(["--resume", session.id]) + with native_node_view(directory) as project: + return self._invoke(command, home, workspace, Path(project), prompt) + + def close(self, session: Session) -> None: + """Release a completed invocation without deleting native state.""" + self._validate_session(session) + + def usage(self) -> Usage: + """Return aggregate token use for this harness instance.""" + with self._usage_lock: + return self._usage + + def _paths(self, directory: str) -> tuple[Path, Path]: + workspace = Path(directory).resolve() / "workspace" + if not workspace.is_dir(): + raise RuntimeError( + "hytorch Claude Code harness: node workspace is unavailable" + ) + # Keep user-level Claude state separate from project-level .claude files. + home = workspace / ".hytorch" / "claude" + home.mkdir(parents=True, exist_ok=True) + return home, workspace + + def _command(self, mtype: str | None) -> list[str]: + return [ + self.binary, + "--print", + "--output-format", + "json", + "--model", + mtype or self.model, + "--permission-mode", + "bypassPermissions", + "--dangerously-skip-permissions", + ] + + def _invoke( + self, + command: list[str], + home: Path, + workspace: Path, + project: Path, + prompt: str, + ) -> Result: + environment = command_environment(values=self.environment) + stable_workspace = project / "workspace" + environment["CLAUDE_CONFIG_DIR"] = str(stable_workspace / ".hytorch" / "claude") + tool_home = workspace / ".hytorch" / "home" + tool_home.mkdir(parents=True, exist_ok=True) + environment["HOME"] = str(stable_workspace / ".hytorch" / "home") + with _credential_overlay(home / ".credentials.json", self.auth_file): + try: + completed = self._runner( + command, + cwd=project, + env=environment, + input=prompt, + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError as exc: + raise RuntimeError( + "hytorch Claude Code harness: executable " + f"{self.binary!r} is unavailable" + ) from exc + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise RuntimeError( + "hytorch Claude Code harness: CLI exited with " + f"{completed.returncode}: {detail}" + ) + text, session_id, usage = _parse_claude_output(completed.stdout) + self._add_usage(usage) + return Result( + text=text, + session=Session(self.name, session_id, str(home)), + ) + + def _add_usage(self, usage: Usage) -> None: + with self._usage_lock: + self._usage = Usage( + input_tokens=self._usage.input_tokens + usage.input_tokens, + output_tokens=self._usage.output_tokens + usage.output_tokens, + cache_read_tokens=( + self._usage.cache_read_tokens + usage.cache_read_tokens + ), + cache_write_tokens=( + self._usage.cache_write_tokens + usage.cache_write_tokens + ), + ) + + def _validate_session(self, session: Session) -> None: + if session.harness != self.name: + raise ValueError( + "hytorch Claude Code harness cannot resume a " + f"{session.harness!r} session" + ) + + @staticmethod + def _validate_sampling(temperature: float | None, max_tokens: int | None) -> None: + # Claude Code has no exact per-turn equivalents for these controls. + del temperature, max_tokens + + +def _latest_claude_session(home: Path) -> str | None: + projects = home / "projects" + if not projects.is_dir(): + return None + candidates = [ + path + for path in projects.rglob("*.jsonl") + if _UUID.fullmatch(path.stem) is not None + ] + if not candidates: + return None + return max( + candidates, + key=lambda path: (path.stat().st_mtime_ns, path.as_posix()), + ).stem + + +def _parse_claude_output(output: str) -> tuple[str, str, Usage]: + try: + payload = json.loads(output) + except json.JSONDecodeError as exc: + raise RuntimeError("hytorch Claude Code harness: invalid JSON output") from exc + text = str(payload.get("result", "")).strip() + session_id = str(payload.get("session_id", "")) + raw = payload.get("usage", {}) + usage = Usage( + input_tokens=int(raw.get("input_tokens", 0)), + output_tokens=int(raw.get("output_tokens", 0)), + cache_read_tokens=int(raw.get("cache_read_input_tokens", 0)), + cache_write_tokens=int(raw.get("cache_creation_input_tokens", 0)), + ) + if payload.get("is_error") or not text or not session_id: + raise RuntimeError("hytorch Claude Code harness: incomplete CLI result") + return text, session_id, usage + + +@contextmanager +def _credential_overlay(target: Path, source: str | None): + if target.exists(): + raise RuntimeError( + "hytorch Claude Code harness: credentials must not be stored in agent state" + ) + if source is not None: + source_path = Path(source).expanduser().resolve() + if not source_path.is_file(): + raise RuntimeError("hytorch Claude Code harness: auth file is unavailable") + shutil.copy2(source_path, target) + try: + yield + finally: + # Also remove a credential that the CLI created from runtime auth. + target.unlink(missing_ok=True) + + +__all__ = ["ClaudeCodeHarness"] diff --git a/hytorch/codex_harness.py b/hytorch/codex_harness.py new file mode 100644 index 0000000..8c5dd0d --- /dev/null +++ b/hytorch/codex_harness.py @@ -0,0 +1,240 @@ +"""Local Codex CLI harness with node-local, persistent native state.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import threading +from collections.abc import Callable, Mapping +from contextlib import contextmanager +from pathlib import Path + +from ._environment import command_environment +from ._native_view import native_node_view +from .harness import Harness, Result, Session, Usage + +_DEFAULT_MODEL = "gpt-5.6-terra" + + +class CodexHarness(Harness): + """Run one persistent Codex CLI session from each node workspace. + + ``workspace/.hytorch/codex`` is the node's opaque ``CODEX_HOME``. HyTorch can + version the complete workspace while authentication remains an external + runtime input. + """ + + name = "codex" + + def __init__( + self, + name: str | None = None, + *, + model: str = _DEFAULT_MODEL, + binary: str = "codex", + environment: Mapping[str, str] | None = None, + auth_file: str | None = None, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, + ) -> None: + super().__init__(name) + self.model = model + self.binary = binary + self.environment = dict(environment or {}) + self.auth_file = auth_file + self._runner = runner + self._usage = Usage() + self._usage_lock = threading.Lock() + + def start( + self, + directory: str, + prompt: str, + mtype: str | None, + *, + temperature: float | None = None, + max_tokens: int | None = None, + read_only: tuple[str, ...] = (), + ) -> Result: + """Start a node session, or continue the session in its workspace state.""" + self._validate_sampling(temperature, max_tokens) + home, workspace = self._paths(directory) + with native_node_view(directory) as project: + command = self._command(Path(project), mtype) + if _has_codex_session(home): + command.extend(["resume", "--last", "--all", "-"]) + else: + command.append("-") + return self._invoke(command, home, workspace, Path(project), prompt) + + def resume( + self, + session: Session, + directory: str, + prompt: str, + mtype: str | None, + *, + temperature: float | None = None, + max_tokens: int | None = None, + read_only: tuple[str, ...] = (), + ) -> Result: + """Append one turn to the node's current Codex session.""" + self._validate_session(session) + self._validate_sampling(temperature, max_tokens) + home, workspace = self._paths(directory) + with native_node_view(directory) as project: + command = self._command(Path(project), mtype) + command.extend(["resume", session.id, "-"]) + return self._invoke(command, home, workspace, Path(project), prompt) + + def close(self, session: Session) -> None: + """Release a completed invocation without deleting native state.""" + self._validate_session(session) + + def usage(self) -> Usage: + """Return aggregate token use for this harness instance.""" + with self._usage_lock: + return self._usage + + def _paths(self, directory: str) -> tuple[Path, Path]: + workspace = Path(directory).resolve() / "workspace" + if not workspace.is_dir(): + raise RuntimeError("hytorch Codex harness: node workspace is unavailable") + # Keep user-level Codex state separate from project-level .codex files. + home = workspace / ".hytorch" / "codex" + home.mkdir(parents=True, exist_ok=True) + return home, workspace + + def _command(self, workspace: Path, mtype: str | None) -> list[str]: + return [ + self.binary, + "exec", + "--json", + "--model", + mtype or self.model, + "--skip-git-repo-check", + "--sandbox", + "danger-full-access", + "--cd", + str(workspace), + ] + + def _invoke( + self, + command: list[str], + home: Path, + workspace: Path, + project: Path, + prompt: str, + ) -> Result: + environment = command_environment(values=self.environment) + stable_workspace = project / "workspace" + environment["CODEX_HOME"] = str(stable_workspace / ".hytorch" / "codex") + tool_home = workspace / ".hytorch" / "home" + tool_home.mkdir(parents=True, exist_ok=True) + environment["HOME"] = str(stable_workspace / ".hytorch" / "home") + with _credential_overlay(home / "auth.json", self.auth_file): + try: + completed = self._runner( + command, + cwd=project, + env=environment, + input=prompt, + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError as exc: + raise RuntimeError( + f"hytorch Codex harness: executable {self.binary!r} is unavailable" + ) from exc + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise RuntimeError( + f"hytorch Codex harness: CLI exited with {completed.returncode}: {detail}" + ) + text, session_id, usage = _parse_codex_output(completed.stdout) + self._add_usage(usage) + return Result( + text=text, + session=Session(self.name, session_id, str(home)), + ) + + def _add_usage(self, usage: Usage) -> None: + with self._usage_lock: + self._usage = Usage( + input_tokens=self._usage.input_tokens + usage.input_tokens, + output_tokens=self._usage.output_tokens + usage.output_tokens, + cache_read_tokens=( + self._usage.cache_read_tokens + usage.cache_read_tokens + ), + cache_write_tokens=( + self._usage.cache_write_tokens + usage.cache_write_tokens + ), + ) + + def _validate_session(self, session: Session) -> None: + if session.harness != self.name: + raise ValueError( + f"hytorch Codex harness cannot resume a {session.harness!r} session" + ) + + @staticmethod + def _validate_sampling(temperature: float | None, max_tokens: int | None) -> None: + # Codex CLI has no stable flags for these generic optimizer controls. + del temperature, max_tokens + + +def _has_codex_session(home: Path) -> bool: + sessions = home / "sessions" + return sessions.is_dir() and any(sessions.rglob("*.jsonl")) + + +def _parse_codex_output(output: str) -> tuple[str, str, Usage]: + session_id = "" + text = "" + usage = Usage() + try: + events = [json.loads(line) for line in output.splitlines() if line.strip()] + except json.JSONDecodeError as exc: + raise RuntimeError("hytorch Codex harness: invalid JSONL output") from exc + for event in events: + if event.get("type") == "thread.started": + session_id = str(event.get("thread_id", "")) + item = event.get("item", {}) + if ( + event.get("type") == "item.completed" + and item.get("type") == "agent_message" + ): + text = str(item.get("text", "")).strip() + if event.get("type") == "turn.completed": + raw = event.get("usage", {}) + usage = Usage( + input_tokens=int(raw.get("input_tokens", 0)), + output_tokens=int(raw.get("output_tokens", 0)), + cache_read_tokens=int(raw.get("cached_input_tokens", 0)), + ) + if not session_id or not text: + raise RuntimeError("hytorch Codex harness: incomplete CLI result") + return text, session_id, usage + + +@contextmanager +def _credential_overlay(target: Path, source: str | None): + if target.exists(): + raise RuntimeError( + "hytorch Codex harness: credentials must not be stored in agent state" + ) + if source is not None: + source_path = Path(source).expanduser().resolve() + if not source_path.is_file(): + raise RuntimeError("hytorch Codex harness: auth file is unavailable") + shutil.copy2(source_path, target) + try: + yield + finally: + # Also remove a credential that the CLI created from runtime auth. + target.unlink(missing_ok=True) + + +__all__ = ["CodexHarness"] diff --git a/hytorch/harness.py b/hytorch/harness.py index 9d29fc5..6eefff7 100644 --- a/hytorch/harness.py +++ b/hytorch/harness.py @@ -36,7 +36,7 @@ def __sub__(self, other: Usage) -> Usage: @dataclasses.dataclass(frozen=True) class Result: - """Result of starting a persisted harness session.""" + """Text and opaque native session tip returned by one harness turn.""" text: str session: Session @@ -83,13 +83,13 @@ def resume( temperature: float | None = None, max_tokens: int | None = None, read_only: tuple[str, ...] = (), - ) -> str: - """Resume a saved session in ``directory`` and execute one prompt.""" + ) -> Result: + """Resume a saved session and return its new opaque tip.""" raise NotImplementedError @abc.abstractmethod def close(self, session: Session) -> None: - """Release the persisted session and its harness-owned storage.""" + """Release runtime resources without deleting persisted agent state.""" raise NotImplementedError @@ -118,7 +118,7 @@ def resume( temperature: float | None = None, max_tokens: int | None = None, read_only: tuple[str, ...] = (), - ) -> str: + ) -> Result: raise RuntimeError(f"hytorch harness {self.name!r} is built in but unavailable") def close(self, session: Session) -> None: @@ -126,7 +126,7 @@ def close(self, session: Session) -> None: class PiHarness(Harness): - """The built-in Pi harness. Pi is the sole executable 0.1.0 runtime.""" + """The built-in Pi harness.""" name = "pi" @@ -165,7 +165,66 @@ def resume( temperature: float | None = None, max_tokens: int | None = None, read_only: tuple[str, ...] = (), - ) -> str: + ) -> Result: + return self._runtime.resume( + session, + directory, + prompt, + mtype, + temperature=temperature, + max_tokens=max_tokens, + read_only=read_only, + ) + + def close(self, session: Session) -> None: + self._runtime.close(session) + + def usage(self) -> Usage: + """Return aggregate token use for this harness instance.""" + return self._runtime.usage() + + +class PrimeAgentHarness(Harness): + """The built-in Prime Agent harness.""" + + name = "prime-agent" + + def __init__(self, name: str | None = None, **kwargs) -> None: + from .prime_harness import PrimeRuntime + + super().__init__(name) + self._runtime = PrimeRuntime(harness_name=self.name, **kwargs) + + def start( + self, + directory: str, + prompt: str, + mtype: str | None, + *, + temperature: float | None = None, + max_tokens: int | None = None, + read_only: tuple[str, ...] = (), + ) -> Result: + return self._runtime.start( + directory, + prompt, + mtype, + temperature=temperature, + max_tokens=max_tokens, + read_only=read_only, + ) + + def resume( + self, + session: Session, + directory: str, + prompt: str, + mtype: str | None, + *, + temperature: float | None = None, + max_tokens: int | None = None, + read_only: tuple[str, ...] = (), + ) -> Result: return self._runtime.resume( session, directory, @@ -218,22 +277,38 @@ def name_of(harness: Harness | str) -> str: ) -# Stable built-ins. Only Pi has an executable implementation in 0.1.0. +from .claude_harness import ClaudeCodeHarness # noqa: E402 +from .codex_harness import CodexHarness # noqa: E402 +from .hermes_harness import HermesHarness # noqa: E402 +from .opencode_harness import OpenCodeHarness # noqa: E402 + +# Stable executable built-ins. pi = register(PiHarness()) -codex = register(UnavailableHarness("codex")) -claude_code = register(UnavailableHarness("claude-code")) +codex = register(CodexHarness()) +claude_code = register(ClaudeCodeHarness()) +opencode = register(OpenCodeHarness()) +hermes = register(HermesHarness()) +prime_agent = register(PrimeAgentHarness()) __all__ = [ "Harness", + "ClaudeCodeHarness", + "CodexHarness", + "HermesHarness", + "OpenCodeHarness", "PiHarness", + "PrimeAgentHarness", "Result", "Session", "Usage", "UnavailableHarness", "claude_code", "codex", + "hermes", "name_of", "pi", + "opencode", + "prime_agent", "register", "registered", ] diff --git a/hytorch/hermes_harness.py b/hytorch/hermes_harness.py new file mode 100644 index 0000000..8c3d6d4 --- /dev/null +++ b/hytorch/hermes_harness.py @@ -0,0 +1,318 @@ +"""Local Hermes Agent CLI harness with an isolated native profile.""" + +from __future__ import annotations + +import os +import re +import shutil +import sqlite3 +import subprocess +from collections.abc import Callable, Mapping + +from ._environment import command_environment +from ._native_view import native_node_view +from .harness import Harness, Result, Session + +Runner = Callable[..., subprocess.CompletedProcess[str]] +_SESSION_ID = re.compile( + r"^[ \t]*session_id:[ \t]*(\S+)[ \t]*$", re.IGNORECASE | re.MULTILINE +) +_SECRET_FILES = (".env", "auth.json", ".anthropic_oauth.json") + + +class HermesHarness(Harness): + """Run one persistent Hermes session from a node's private workspace. + + ``HERMES_HOME`` is the complete opaque agent profile. Optional credential + files are copied into that home only while Hermes runs. They are copied + back to their external sidecars after token refresh and then removed from + the profile before HyTorch can promote it. + """ + + name = "hermes" + + def __init__( + self, + name: str | None = None, + *, + provider: str | None = None, + model: str | None = None, + binary: str = "hermes", + environment: Mapping[str, str] | None = None, + credential_files: Mapping[str, str] | None = None, + runner: Runner = subprocess.run, + ) -> None: + super().__init__(name) + self.provider = provider + self.model = model + self.binary = binary + self._environment = dict(environment or {}) + self._credential_files = _validate_credential_files(credential_files or {}) + self._runner = runner + + def start( + self, + directory: str, + prompt: str, + mtype: str | None, + *, + temperature: float | None = None, + max_tokens: int | None = None, + read_only: tuple[str, ...] = (), + ) -> Result: + """Continue the profile's latest session, or create its first one.""" + return self._invoke( + directory, + prompt, + mtype, + session_id=None, + temperature=temperature, + max_tokens=max_tokens, + ) + + def resume( + self, + session: Session, + directory: str, + prompt: str, + mtype: str | None, + *, + temperature: float | None = None, + max_tokens: int | None = None, + read_only: tuple[str, ...] = (), + ) -> Result: + """Resume Hermes and return its current compression-lineage tip.""" + self._validate_session(session) + return self._invoke( + directory, + prompt, + mtype, + session_id=session.id, + temperature=temperature, + max_tokens=max_tokens, + ) + + def close(self, session: Session) -> None: + """Detach from a session without deleting its persistent profile.""" + self._validate_session(session) + + def _invoke( + self, + directory: str, + prompt: str, + mtype: str | None, + *, + session_id: str | None, + temperature: float | None, + max_tokens: int | None, + ) -> Result: + del temperature # Hermes has no stable per-turn temperature flag. + _, _, profile = _node_paths(directory) + with native_node_view(directory) as execution_root: + return self._invoke_at( + execution_root, profile, prompt, mtype, session_id, max_tokens + ) + + def _invoke_at( + self, + execution_root: str, + profile: str, + prompt: str, + mtype: str | None, + session_id: str | None, + max_tokens: int | None, + ) -> Result: + stable_profile = os.path.join(execution_root, "workspace") + environment = self._profile_environment( + execution_root, profile, stable_profile, max_tokens + ) + session_id = session_id or _saved_session(profile) + command = [ + self.binary, + "chat", + "-Q", + "-q", + prompt, + "--yolo", + "--no-restore-cwd", + ] + if session_id is None and _has_hermes_session(profile): + command.append("--continue") + elif session_id is not None: + command.extend(("--resume", session_id)) + if self.provider: + command.extend(("--provider", self.provider)) + model = mtype or self.model + if model: + command.extend(("--model", model)) + + self._stage_credentials(profile) + try: + try: + completed = self._runner( + command, + cwd=execution_root, + env=environment, + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError as exc: + raise RuntimeError( + f"hytorch Hermes harness: executable {self.binary!r} is unavailable" + ) from exc + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise RuntimeError( + f"hytorch Hermes harness: exit {completed.returncode}: {detail}" + ) + resolved_id = _parse_hermes_session_id(completed.stderr) + _save_session(profile, resolved_id) + text = completed.stdout.strip() + if not text: + raise RuntimeError("hytorch Hermes harness: output has no final text") + return Result( + text=text, + session=Session( + harness=self.name, + id=resolved_id, + storage=profile, + ), + ) + finally: + self._unstage_credentials(profile) + + def _profile_environment( + self, + execution_root: str, + profile: str, + stable_profile: str, + max_tokens: int | None, + ) -> dict[str, str]: + home = os.path.join(profile, "home") + os.makedirs(home, exist_ok=True) + environment = command_environment(values=self._environment) + environment.update( + { + "HOME": os.path.join(stable_profile, "home"), + "HERMES_HOME": stable_profile, + "TERMINAL_CWD": execution_root, + } + ) + if max_tokens is not None: + environment["HERMES_MAX_TOKENS"] = str(max_tokens) + return environment + + def _stage_credentials(self, profile: str) -> None: + for name in _SECRET_FILES: + destination = os.path.join(profile, name) + if not os.path.lexists(destination): + continue + if name in self._credential_files: + message = "credential overlay target already exists" + else: + message = "credentials must not be stored in agent state" + raise RuntimeError(f"hytorch Hermes harness: {message}") + for name in _SECRET_FILES: + destination = os.path.join(profile, name) + source = self._credential_files.get(name) + if source is None: + continue + shutil.copy2(source, destination) + os.chmod(destination, 0o600) + + def _unstage_credentials(self, profile: str) -> None: + for name in _SECRET_FILES: + candidate = os.path.join(profile, name) + sidecar = self._credential_files.get(name) + if sidecar is not None and os.path.isfile(candidate): + temporary = sidecar + ".hytorch.tmp" + shutil.copy2(candidate, temporary) + os.chmod(temporary, 0o600) + os.replace(temporary, sidecar) + if os.path.lexists(candidate): + os.remove(candidate) + + def _validate_session(self, session: Session) -> None: + if session.harness != self.name: + raise ValueError( + f"hytorch Hermes harness cannot use a {session.harness!r} session" + ) + if not session.id.strip(): + raise ValueError("hytorch Hermes harness: session id is empty") + + +def _validate_credential_files(values: Mapping[str, str]) -> dict[str, str]: + result: dict[str, str] = {} + for name, path in values.items(): + if name not in _SECRET_FILES: + raise ValueError( + f"hytorch Hermes harness: unsupported credential file {name!r}" + ) + resolved = os.path.realpath(os.path.expanduser(path)) + if not os.path.isfile(resolved): + raise ValueError( + f"hytorch Hermes harness: credential file is unavailable: {resolved}" + ) + result[name] = resolved + return result + + +def _node_paths(directory: str) -> tuple[str, str, str]: + root = os.path.realpath(directory) + statespace = os.path.join(root, "statespace") + profile = os.path.join(root, "workspace") + if not os.path.isdir(statespace): + raise RuntimeError("hytorch harness: node statespace directory is unavailable") + if not os.path.isdir(profile): + raise RuntimeError("hytorch harness: node workspace directory is unavailable") + return root, statespace, profile + + +def _has_hermes_session(profile: str) -> bool: + database = os.path.join(profile, "state.db") + if not os.path.isfile(database): + return False + try: + connection = sqlite3.connect(f"file:{database}?mode=ro", uri=True) + try: + row = connection.execute( + "SELECT 1 FROM sessions WHERE source = 'cli' LIMIT 1" + ).fetchone() + finally: + connection.close() + except sqlite3.Error as exc: + raise RuntimeError( + "hytorch Hermes harness: native session database is unreadable" + ) from exc + return row is not None + + +def _parse_hermes_session_id(stderr: str) -> str: + matches = _SESSION_ID.findall(stderr) + if not matches: + raise RuntimeError("hytorch Hermes harness: output has no session id") + return matches[-1] + + +def _session_pointer(profile: str) -> str: + return os.path.join(profile, ".hytorch", "hermes-session") + + +def _saved_session(profile: str) -> str | None: + path = _session_pointer(profile) + if not os.path.isfile(path): + return None + with open(path, encoding="utf-8") as file: + value = file.read().strip() + return value or None + + +def _save_session(profile: str, session_id: str) -> None: + path = _session_pointer(profile) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as file: + file.write(session_id + "\n") + + +__all__ = ["HermesHarness"] diff --git a/hytorch/linear.py b/hytorch/linear.py index 506e032..103a3b4 100644 --- a/hytorch/linear.py +++ b/hytorch/linear.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +import shutil import tempfile from ._autofeed import Node @@ -13,9 +14,10 @@ from .parameter import ( Parameter, ParameterStore, - create_workspace_checkout, + materialize_agent_state, set_tree_writable, tree_manifest, + validate_agent_state, ) from .space import Space, SpaceBatch @@ -157,6 +159,7 @@ def _run_instance( branch = "hytorch-integration" root = tempfile.mkdtemp(prefix="hytorch-node-") statespace = os.path.join(root, "statespace") + parameter = os.path.join(root, "parameter") workspace = os.path.join(root, "workspace") commits = [value.commit for value in inputs] statespace_repo, integration_base = Repo.create_integration( @@ -166,40 +169,47 @@ def _run_instance( weight = self.weight[index] workspace_revision = weight.revision - workspace_repo = create_workspace_checkout( - weight.parameter._store.root, + materialize_agent_state( + weight.parameter._store, workspace_revision, weight.relative_path, - workspace, + parameter, ) - workspace_before = tree_manifest(workspace) - workspace_head = workspace_repo.resolve("HEAD") - set_tree_writable(workspace, False) + validate_agent_state(parameter) + shutil.copytree(parameter, workspace, symlinks=True) + episode_identity = os.path.join(workspace, ".hytorch", "node-id") + if os.path.isfile(episode_identity): + os.remove(episode_identity) + parameter_before = tree_manifest(parameter) + set_tree_writable(parameter, False) prompt = self._build_prompt( task, index, workspace_revision, weight.relative_path ) - result = harness.start( - root, - prompt, - mtype, - read_only=(workspace,), - ) try: - unexpected = sorted(set(os.listdir(root)) - {"workspace", "statespace"}) + result = harness.start( + root, + prompt, + mtype, + read_only=(parameter,), + ) + except Exception: + shutil.rmtree(root, ignore_errors=True) + raise + try: + validate_agent_state(workspace) + if tree_manifest(parameter) != parameter_before: + raise RuntimeError( + f"hytorch.mn.Linear {self.id()}: forward modified the read-only Parameter" + ) + unexpected = sorted( + set(os.listdir(root)) - {"parameter", "workspace", "statespace"} + ) if unexpected: raise RuntimeError( f"hytorch.mn.Linear {self.id()}: forward changed paths outside the statespace: " + ", ".join(unexpected) ) - if tree_manifest(workspace) != workspace_before: - raise RuntimeError( - f"hytorch.mn.Linear {self.id()}: forward modified read-only workspace" - ) - if workspace_repo.resolve("HEAD") != workspace_head: - raise RuntimeError( - f"hytorch.mn.Linear {self.id()}: forward changed workspace Git state" - ) if not statespace_repo.is_clean(): raise RuntimeError( f"hytorch.mn.Linear {self.id()}: forward finished with uncommitted " @@ -231,10 +241,14 @@ def _run_instance( commit = statespace_repo.commit_allow_empty(statespace, message) except Exception: harness.close(result.session) + shutil.rmtree(root, ignore_errors=True) raise if inference: harness.close(result.session) + shutil.rmtree(workspace, ignore_errors=True) + set_tree_writable(parameter, True) + shutil.rmtree(parameter, ignore_errors=True) return Space( statespace, repo=statespace_repo, @@ -249,7 +263,7 @@ def _run_instance( feed_fn=None, ) - parameters = (weight,) if weight.parameter.requires_feed else () + parameters = (weight,) parents = _deduplicate_nodes( [value.feed_fn for value in inputs if value.feed_fn is not None] ) @@ -266,6 +280,7 @@ def _run_instance( commit=commit, root=root, workspace=workspace, + parameter=parameter, statespace=statespace, workspace_revision=workspace_revision, ) @@ -279,8 +294,8 @@ def _run_instance( summary=result.text, harness=harness_name, mtype=mtype, - requires_feed=bool(parameters or parents), - feed_fn=node, + requires_feed=bool(weight.parameter.requires_feed or parents), + feed_fn=node if weight.parameter.requires_feed or parents else None, ) def _build_prompt( @@ -292,10 +307,14 @@ def _build_prompt( ) -> str: parts = [ f"Run node {self.id()}[{index}] at workspace revision {workspace_revision}.\n\n", - "The node root contains two directories:\n", + "The node root contains three directories:\n", "- statespace/: writable self-contained Git state\n", - "- workspace/: read-only sparse model checkout with full model history\n\n", - f"Your learned workspace is workspace/{workspace_path}/.\n", + "- parameter/: read-only canonical native state\n", + "- workspace/: writable temporary episode fork\n\n", + f"HyTorch materialized parameter/ from private model path {workspace_path}. ", + "The directories have no model Git metadata. Use workspace/ for temporary " + "session state. Forward changes to workspace/ are never promoted. " + "Do not modify parameter/.\n", f"The {self.in_features} inputs are Git refs in statespace/:\n", *[ f"- refs/hytorch/inputs/{input_index}\n" @@ -305,9 +324,9 @@ def _build_prompt( "merge order. Merge every input ref with --no-ff and " "--allow-unrelated-histories. Resolve and commit each merge before the " "next merge. You can make more statespace changes after merging. Commit " - "all changes before ending your turn. Leave the statespace repository " + "all statespace changes before ending your turn. Leave the statespace repository " "clean. Your final committed statespace HEAD is this node's output and is " - "passed to successor agents. Do not modify workspace/.\n", + "passed to successor agents. Do not run Git commands in workspace/.\n", ] if task: parts.extend(["\n# Task\n\n", task, "\n"]) diff --git a/hytorch/opencode_harness.py b/hytorch/opencode_harness.py new file mode 100644 index 0000000..d4af906 --- /dev/null +++ b/hytorch/opencode_harness.py @@ -0,0 +1,275 @@ +"""Local OpenCode CLI harness with an isolated native agent profile.""" + +from __future__ import annotations + +import json +import os +import subprocess +from collections.abc import Callable, Mapping +from typing import Any + +from ._environment import command_environment +from ._native_view import native_node_view +from .harness import Harness, Result, Session + +Runner = Callable[..., subprocess.CompletedProcess[str]] + + +class OpenCodeHarness(Harness): + """Run one persistent OpenCode session from a node's private workspace. + + OpenCode stores its native session, instructions, skills, configuration, + and other local state below XDG directories. This harness places all of + those directories below ``workspace/``. Provider credentials stay in the + process environment and are not part of that profile. + """ + + name = "opencode" + + def __init__( + self, + name: str | None = None, + *, + model: str | None = None, + binary: str = "opencode", + environment: Mapping[str, str] | None = None, + runner: Runner = subprocess.run, + ) -> None: + super().__init__(name) + self.model = model + self.binary = binary + self._environment = dict(environment or {}) + self._runner = runner + + def start( + self, + directory: str, + prompt: str, + mtype: str | None, + *, + temperature: float | None = None, + max_tokens: int | None = None, + read_only: tuple[str, ...] = (), + ) -> Result: + """Continue the profile's latest session, or create its first one.""" + return self._invoke( + directory, + prompt, + mtype, + session_id=None, + temperature=temperature, + max_tokens=max_tokens, + ) + + def resume( + self, + session: Session, + directory: str, + prompt: str, + mtype: str | None, + *, + temperature: float | None = None, + max_tokens: int | None = None, + read_only: tuple[str, ...] = (), + ) -> Result: + """Execute another turn in an exact OpenCode session.""" + self._validate_session(session) + return self._invoke( + directory, + prompt, + mtype, + session_id=session.id, + temperature=temperature, + max_tokens=max_tokens, + ) + + def close(self, session: Session) -> None: + """Detach from a session without deleting its persistent profile.""" + self._validate_session(session) + + def _invoke( + self, + directory: str, + prompt: str, + mtype: str | None, + *, + session_id: str | None, + temperature: float | None, + max_tokens: int | None, + ) -> Result: + del temperature # OpenCode has no stable per-turn temperature flag. + _, _, profile = _node_paths(directory) + with native_node_view(directory) as execution_root: + return self._invoke_at( + execution_root, profile, prompt, mtype, session_id, max_tokens + ) + + def _invoke_at( + self, + execution_root: str, + profile: str, + prompt: str, + mtype: str | None, + session_id: str | None, + max_tokens: int | None, + ) -> Result: + environment = self._profile_environment( + profile, os.path.join(execution_root, "workspace"), max_tokens + ) + session_id = session_id or _saved_session(profile) + model = mtype or self.model + command = [ + self.binary, + "run", + "--format", + "json", + "--dir", + execution_root, + "--auto", + ] + if session_id is not None: + command.extend(("--session", session_id)) + if model: + command.extend(("--model", model)) + + auth_file = os.path.join(profile, "data", "opencode", "auth.json") + if os.path.lexists(auth_file): + raise RuntimeError( + "hytorch OpenCode harness: credentials must not be stored " + "in agent state" + ) + try: + try: + completed = self._runner( + command, + cwd=execution_root, + env=environment, + input=prompt, + capture_output=True, + text=True, + check=False, + ) + except FileNotFoundError as exc: + raise RuntimeError( + "hytorch OpenCode harness: executable " + f"{self.binary!r} is unavailable" + ) from exc + finally: + # Also remove credentials created from runtime authentication. + if os.path.lexists(auth_file): + os.remove(auth_file) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + raise RuntimeError( + f"hytorch OpenCode harness: exit {completed.returncode}: {detail}" + ) + text, resolved_id = _parse_opencode_output(completed.stdout) + _save_session(profile, resolved_id) + return Result( + text=text, + session=Session( + harness=self.name, + id=resolved_id, + storage=profile, + ), + ) + + def _profile_environment( + self, profile: str, stable_profile: str, max_tokens: int | None + ) -> dict[str, str]: + actual_locations = { + "HOME": os.path.join(profile, "home"), + "XDG_DATA_HOME": os.path.join(profile, "data"), + "XDG_CONFIG_HOME": os.path.join(profile, "config"), + "XDG_STATE_HOME": os.path.join(profile, "state"), + "XDG_CACHE_HOME": os.path.join(profile, "cache"), + } + for path in actual_locations.values(): + os.makedirs(path, exist_ok=True) + locations = { + name: os.path.join(stable_profile, os.path.basename(path)) + for name, path in actual_locations.items() + } + environment = command_environment(values=self._environment) + environment.update(locations) + environment.update( + { + "OPENCODE_AUTO_SHARE": "false", + "OPENCODE_DISABLE_AUTOUPDATE": "true", + "OPENCODE_DISABLE_CLAUDE_CODE": "true", + } + ) + if max_tokens is not None: + environment["OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"] = str(max_tokens) + return environment + + def _validate_session(self, session: Session) -> None: + if session.harness != self.name: + raise ValueError( + f"hytorch OpenCode harness cannot use a {session.harness!r} session" + ) + if not session.id.strip(): + raise ValueError("hytorch OpenCode harness: session id is empty") + + +def _node_paths(directory: str) -> tuple[str, str, str]: + root = os.path.realpath(directory) + statespace = os.path.join(root, "statespace") + profile = os.path.join(root, "workspace") + if not os.path.isdir(statespace): + raise RuntimeError("hytorch harness: node statespace directory is unavailable") + if not os.path.isdir(profile): + raise RuntimeError("hytorch harness: node workspace directory is unavailable") + return root, statespace, profile + + +def _parse_opencode_output(output: str) -> tuple[str, str]: + session_id = "" + final_text = "" + for number, source in enumerate(output.splitlines(), 1): + if not source.strip(): + continue + try: + event: Any = json.loads(source) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"hytorch OpenCode harness: invalid JSON event on line {number}" + ) from exc + if not isinstance(event, dict): + continue + current_id = event.get("sessionID") + if isinstance(current_id, str) and current_id: + session_id = current_id + if event.get("type") != "text": + continue + part = event.get("part") + if isinstance(part, dict) and isinstance(part.get("text"), str): + final_text = part["text"].strip() + if not session_id: + raise RuntimeError("hytorch OpenCode harness: output has no session id") + if not final_text: + raise RuntimeError("hytorch OpenCode harness: output has no final text") + return final_text, session_id + + +def _session_pointer(profile: str) -> str: + return os.path.join(profile, "state", "hytorch", "opencode-session") + + +def _saved_session(profile: str) -> str | None: + path = _session_pointer(profile) + if not os.path.isfile(path): + return None + with open(path, encoding="utf-8") as file: + value = file.read().strip() + return value or None + + +def _save_session(profile: str, session_id: str) -> None: + path = _session_pointer(profile) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as file: + file.write(session_id + "\n") + + +__all__ = ["OpenCodeHarness"] diff --git a/hytorch/optim/dfm.py b/hytorch/optim/dfm.py index ed1f0b5..5637c48 100644 --- a/hytorch/optim/dfm.py +++ b/hytorch/optim/dfm.py @@ -1,10 +1,12 @@ -"""Directional Feedback Mutation over candidate workspace branches.""" +"""Directional Feedback Mutation for persistent agent Parameters.""" from __future__ import annotations import dataclasses +import hashlib import json import os +import shutil import tempfile import time from collections import defaultdict @@ -18,34 +20,49 @@ from ..parameter import ( Parameter, ParameterStore, - create_workspace_checkout, + copy_tree, set_tree_writable, tree_manifest, + validate_agent_state, ) from ..space import Space from .optimizer import Optimizer, _release -@dataclasses.dataclass -class _PendingUpdate: - store: ParameterStore - branch: str - root: str - base: str - before: dict[str, dict[str, bytes]] +@dataclasses.dataclass(frozen=True) +class FeedRecord: + """One reproducible mutation direction for an owner Parameter.""" + + text: str + layer: str + agent: int + session: str + output_commit: str + input_commits: tuple[str, ...] + downstream: tuple[str, ...] + harness: str + mtype: str | None + digest: str @dataclasses.dataclass class _NodeUpdate: context: Node + update: str upstream: list[str] - workspace_base: str - workspace_head: str - workspace_path: str + downstream: tuple[str, ...] + + +@dataclasses.dataclass +class _Candidate: + store: ParameterStore + branch: str + root: str + base: str class DFM(Optimizer): - """Generate workspace candidates during backward and promote them in step.""" + """Accumulate directional feed and update each Parameter once in step().""" def __init__( self, @@ -67,13 +84,17 @@ def __init__( ) self.temp = float(temp) self.max_tokens = max_tokens - self._pending: _PendingUpdate | None = None + self._records: dict[tuple[int, str], list[FeedRecord]] = defaultdict(list) + self._views = {} + self._pending: _Candidate | None = None - def _backward(self, output: Space, feedback: str) -> None: - if self._pending is not None: - raise RuntimeError( - "hytorch.optim.DFM: call step() or zero_feed() before another backward pass" - ) + def _backward( + self, + output: Space, + feedback: str, + *, + retain_graph: bool = False, + ) -> None: nodes = ancestors([output.feed_fn]) views = [view for node in nodes for view in node.parameters] if not views: @@ -89,39 +110,41 @@ def _backward(self, output: Space, feedback: str) -> None: raise RuntimeError( "hytorch.optim.DFM: optimizer does not own every executed Parameter" ) - - store = next(iter(stores.values())) - base = store.repo.resolve("HEAD") - branch = f"hytorch/dfm/{time.time_ns()}" - candidate_root = tempfile.mkdtemp(prefix="hytorch-dfm-") - store.repo.branch(branch, base) - store.repo.add_worktree(candidate_root, branch) - before = { - view.relative_path: tree_manifest( - os.path.join(store.root, view.relative_path) + if self._pending is not None: + raise RuntimeError( + "hytorch.optim.DFM: an earlier step candidate has not been resolved" ) - for parameter in self.params - for view in parameter.views() - } - pending = _PendingUpdate(store, branch, candidate_root, base, before) try: - self._run_backward(nodes, output.feed_fn, feedback, pending) + updates = self._run_backward(nodes, output.feed_fn, feedback) + for value in updates: + view = value.context.parameters[0] + if not view.parameter.requires_feed: + value.context.feed = value.downstream + value.context.applied = True + continue + record = _feed_record(value) + key = (id(view.parameter._store), view.relative_path) + self._records[key].append(record) + self._views[key] = view + view._accumulate_feed(value.update) + value.context.feed = value.downstream + value.context.applied = True + if not retain_graph: + for node in nodes: + node.consumed = True + if not node.released: + _release(node) except Exception: for node in nodes: + node.consumed = True if not node.released: _release(node) - self._remove_pending(pending) raise - self._pending = pending def _run_backward( - self, - nodes: list[Node], - output_node: Node, - feedback: str, - pending: _PendingUpdate, - ) -> None: + self, nodes: list[Node], output_node: Node, feedback: str + ) -> list[_NodeUpdate]: children: dict[Node, set[Node]] = {node: set() for node in nodes} for node in nodes: for parent in node.parents: @@ -131,88 +154,47 @@ def _run_backward( queue = [node for node in nodes if remaining[node] == 0] downstream: dict[Node, list[str]] = defaultdict(list) downstream[output_node].append(feedback) - processed = 0 + completed: list[_NodeUpdate] = [] while queue: - ready = queue - queue = [] - while ready: - selected: list[Node] = [] - deferred: list[Node] = [] - workspace_paths: set[str] = set() - for node in ready: - if len(node.parameters) != 1: - raise RuntimeError( - "hytorch.optim.DFM: one agent must own one workspace" - ) - path = node.parameters[0].relative_path - if path in workspace_paths: - deferred.append(node) - else: - workspace_paths.add(path) - selected.append(node) - - workspace_base = pending.store.repo.resolve(pending.branch) - for node in selected: - if not downstream[node]: - raise RuntimeError( - f"hytorch.optim.DFM: node {node.layer}[{node.agent}] " - "received no feedback" - ) - with ThreadPoolExecutor(max_workers=len(selected)) as executor: - futures = [ - executor.submit( - self._update_node, - node, - downstream[node], - pending, - workspace_base, - ) - for node in selected - ] - updates = [future.result() for future in futures] - self._integrate_updates(updates, pending) - - for update in updates: - node = update.context - processed += 1 - for input_index, value in enumerate(node.inputs): - parent = value.feed_fn - if parent is not None and parent in remaining: - downstream[parent].append(update.upstream[input_index]) - for parent in node.parents: - if parent not in remaining: - continue + ready, queue = queue, [] + for node in ready: + if not downstream[node]: + raise RuntimeError( + f"hytorch.optim.DFM: node {node.layer}[{node.agent}] received no feedback" + ) + with ThreadPoolExecutor(max_workers=len(ready)) as executor: + futures = [ + executor.submit(self._update_node, node, downstream[node]) + for node in ready + ] + updates = [future.result() for future in futures] + completed.extend(updates) + for update in updates: + node = update.context + for index, value in enumerate(node.inputs): + parent = value.feed_fn + if parent is not None and parent in remaining: + downstream[parent].append(update.upstream[index]) + for parent in node.parents: + if parent in remaining: remaining[parent] -= 1 if remaining[parent] == 0: queue.append(parent) - ready = deferred - - if processed != len(nodes): + if len(completed) != len(nodes): raise RuntimeError("hytorch.optim.DFM: backward graph contains a cycle") + return completed - def _update_node( - self, - context: Node, - messages: list[str], - pending: _PendingUpdate, - workspace_base: str, - ) -> _NodeUpdate: + def _update_node(self, context: Node, messages: list[str]) -> _NodeUpdate: if len(context.parameters) != 1: raise RuntimeError("hytorch.optim.DFM: one agent must own one workspace") - view = context.parameters[0] - set_tree_writable(context.workspace, True) - workspace_repo = create_workspace_checkout( - pending.store.root, - workspace_base, - view.relative_path, - context.workspace, - ) set_tree_writable(context.statespace, False) + set_tree_writable(context.parameter, False) statespace_repo = Repo.discover(context.statespace) statespace_head = statespace_repo.resolve("HEAD") - statespace_before = tree_manifest(context.statespace) - + statespace_before = tree_manifest(context.statespace, include_git=True) + parameter_before = tree_manifest(context.parameter) + view = context.parameters[0] prompt = _backward_prompt( context.layer, context.agent, @@ -222,187 +204,269 @@ def _update_node( self.temp, ) try: - try: - harness = registered()[context.harness] - except KeyError as exc: - raise RuntimeError( - f"hytorch.optim.DFM: harness {context.harness!r} is not registered" - ) from exc - summary = harness.resume( - context.session, - context.root, - prompt, - context.mtype, - temperature=self.temp, - max_tokens=self.max_tokens, - read_only=(context.statespace,), - ) - if tree_manifest(context.statespace) != statespace_before: - raise ValueError( - "hytorch.optim.DFM: backward modified the read-only statespace" - ) - if statespace_repo.resolve("HEAD") != statespace_head: - raise ValueError( - "hytorch.optim.DFM: backward changed statespace Git history" - ) - if not statespace_repo.is_clean(): - raise ValueError("hytorch.optim.DFM: backward left statespace changes") - _validate_node_root(context.root) - if not workspace_repo.is_clean(): - raise RuntimeError( - f"hytorch.optim.DFM: node {context.layer}[{context.agent}] " - "finished with uncommitted workspace changes" - ) - upstream = _read_upstream(summary, len(context.inputs)) - workspace_head = workspace_repo.resolve("HEAD") - if not workspace_repo.is_ancestor(workspace_base, workspace_head): - raise RuntimeError( - f"hytorch.optim.DFM: node {context.layer}[{context.agent}] " - "rewrote global workspace history" - ) - changed_paths = set( - workspace_repo.changed_paths(workspace_base, workspace_head) - ) - prefix = view.relative_path - illegal = sorted( - path - for path in changed_paths - if path != prefix and not path.startswith(prefix + "/") + harness = registered()[context.harness] + except KeyError as exc: + raise RuntimeError( + f"hytorch.optim.DFM: harness {context.harness!r} is not registered" + ) from exc + result = harness.resume( + context.session, + context.root, + prompt, + context.mtype, + temperature=self.temp, + max_tokens=self.max_tokens, + read_only=(context.statespace, context.parameter), + ) + context.session = result.session + if tree_manifest(context.statespace, include_git=True) != statespace_before: + raise ValueError( + "hytorch.optim.DFM: backward modified the read-only statespace" ) - if illegal: - raise ValueError( - "hytorch.optim.DFM: backward changed paths outside its workspace: " - + ", ".join(illegal) - ) - for message in messages: - view._accumulate_feed(message) - context.feed = tuple(messages) - context.applied = True - context.consumed = True - return _NodeUpdate( - context=context, - upstream=upstream, - workspace_base=workspace_base, - workspace_head=workspace_head, - workspace_path=view.relative_path, + if ( + statespace_repo.resolve("HEAD") != statespace_head + or not statespace_repo.is_clean() + ): + raise ValueError( + "hytorch.optim.DFM: backward changed statespace Git history" ) - finally: - _release(context) - - def _integrate_updates( - self, - updates: list[_NodeUpdate], - pending: _PendingUpdate, - ) -> None: - for update in updates: - if update.workspace_head == update.workspace_base: - continue - imported_branch = f"hytorch/import/{time.time_ns()}-{update.context.agent}" - imported = pending.store.repo.import_commit( - update.context.workspace, - update.workspace_head, - imported_branch, + if tree_manifest(context.parameter) != parameter_before: + raise ValueError( + "hytorch.optim.DFM: backward modified the read-only Parameter" ) - try: - current = pending.store.repo.resolve(pending.branch) - if current == update.workspace_base: - pending.store.repo.fast_forward(pending.root, imported) - else: - pending.store.repo.merge_branches(pending.root, [imported]) - finally: - pending.store.repo.delete_branch(imported_branch) + _validate_node_root(context.root) + validate_agent_state(context.workspace) + update, upstream = _read_response(result.text, len(context.inputs)) + return _NodeUpdate(context, update, upstream, tuple(messages)) def step(self) -> None: - """Promote the complete candidate workspace branch.""" - pending = self._pending - if pending is None: + """Reduce all accumulated feed into one atomic Parameter generation.""" + if not self._records: return None - report = Report() + stores = { + id(view.parameter._store): view.parameter._store + for view in self._views.values() + } + if len(stores) != 1 or None in stores.values(): + raise RuntimeError( + "hytorch.optim.DFM: step requires one model workspace store" + ) + store = next(iter(stores.values())) + candidate = self._create_candidate(store) + self._pending = candidate + before = { + view.relative_path: tree_manifest( + os.path.join(store.root, view.relative_path) + ) + for view in self._views.values() + } try: - if pending.store.repo.resolve("HEAD") != pending.base: + keys = sorted(self._records, key=lambda item: item[1]) + with ThreadPoolExecutor(max_workers=len(keys)) as executor: + futures = [ + executor.submit( + self._reduce_owner, + candidate, + self._views[key], + self._records[key], + ) + for key in keys + ] + for future in futures: + future.result() + candidate.store.repo.commit_all_workdir( + candidate.root, "hytorch: apply directional feedback" + ) + if candidate.store.repo.resolve("HEAD") != candidate.base: raise RuntimeError( - "hytorch.optim.DFM: canonical model changed after backward; " - "discarding the stale candidate" + "hytorch.optim.DFM: canonical model changed during step" ) - candidate = pending.store.repo.resolve(pending.branch) - if candidate != pending.base: - pending.store.repo.merge_branches(pending.store.root, [candidate]) - canonical = pending.store.repo.resolve("HEAD") + revision = candidate.store.repo.resolve(candidate.branch) + if revision != candidate.base: + candidate.store.repo.merge_branches(candidate.store.root, [revision]) + report = Report() + canonical = candidate.store.repo.resolve("HEAD") + if canonical != candidate.base: report.commits.append(canonical) - for relative, before in pending.before.items(): - after = tree_manifest(os.path.join(pending.store.root, relative)) - for path in sorted(set(before) | set(after)): - if before.get(path) == after.get(path): - continue - report.revised[os.path.join(relative, path)] = WorkspaceRevision( - before=_as_text(before.get(path)), - after=_as_text(after.get(path)), - ) + for relative, old in before.items(): + new = tree_manifest(os.path.join(store.root, relative)) + for path in sorted(set(old) | set(new)): + if old.get(path) != new.get(path): + report.revised[os.path.join(relative, path)] = ( + WorkspaceRevision( + before=_as_text(old.get(path)), + after=_as_text(new.get(path)), + ) + ) self.state["last_report"] = report + except Exception: + # Feed remains available so the caller can retry or call zero_feed(). + raise finally: - self._remove_pending(pending) + self._remove_candidate(candidate) self._pending = None return None + def _reduce_owner( + self, candidate: _Candidate, view, records: list[FeedRecord] + ) -> None: + root = tempfile.mkdtemp(prefix="hytorch-owner-") + statespace = os.path.join(root, "statespace") + parameter = os.path.join(root, "parameter") + workspace = os.path.join(root, "workspace") + source = os.path.join(candidate.root, view.relative_path) + copy_tree(source, parameter) + copy_tree(source, workspace) + set_tree_writable(parameter, False) + repo, _ = Repo.create_integration(statespace, []) + evidence = [ + dataclasses.asdict(value) for value in sorted(records, key=_record_key) + ] + with open( + os.path.join(statespace, "feeds.json"), "w", encoding="utf-8" + ) as file: + json.dump(evidence, file, indent=2, sort_keys=True) + file.write("\n") + repo.commit_allow_empty(statespace, "hytorch: record accumulated feed") + set_tree_writable(statespace, False) + harness_names = {value.harness for value in records} + model_types = {value.mtype for value in records} + if len(harness_names) != 1 or len(model_types) != 1: + raise RuntimeError( + "hytorch.optim.DFM: one Parameter cannot mix harnesses or model types before step" + ) + harness = registered()[next(iter(harness_names))] + prompt = _step_prompt(view.relative_path, len(records), self.temp) + result = None + try: + result = harness.start( + root, + prompt, + next(iter(model_types)), + temperature=self.temp, + max_tokens=self.max_tokens, + read_only=(statespace, parameter), + ) + if tree_manifest(parameter) != tree_manifest(source): + raise ValueError( + "hytorch.optim.DFM: step modified the read-only Parameter" + ) + validate_agent_state(workspace) + _validate_node_root(root) + copy_tree(workspace, source) + finally: + if result is not None: + harness.close(result.session) + set_tree_writable(parameter, True) + set_tree_writable(statespace, True) + shutil.rmtree(root, ignore_errors=True) + + def _create_candidate(self, store: ParameterStore) -> _Candidate: + base = store.repo.resolve("HEAD") + branch = f"hytorch/dfm/{time.time_ns()}" + root = tempfile.mkdtemp(prefix="hytorch-dfm-") + store.repo.branch(branch, base) + store.repo.add_worktree(root, branch) + return _Candidate(store, branch, root, base) + def _discard_pending(self) -> None: - if self._pending is None: - return - self._remove_pending(self._pending) - self._pending = None + if self._pending is not None: + self._remove_candidate(self._pending) + self._pending = None + self._records.clear() + self._views.clear() @staticmethod - def _remove_pending(pending: _PendingUpdate) -> None: - if os.path.isdir(pending.root): - pending.store.repo.remove_worktree(pending.root) + def _remove_candidate(candidate: _Candidate) -> None: + if os.path.isdir(candidate.root): + candidate.store.repo.remove_worktree(candidate.root) try: - pending.store.repo.delete_branch(pending.branch) + candidate.store.repo.delete_branch(candidate.branch) except Exception: pass +def _feed_record(value: _NodeUpdate) -> FeedRecord: + context = value.context + payload = { + "text": value.update, + "layer": context.layer, + "agent": context.agent, + "session": context.session.id, + "output_commit": context.commit, + "input_commits": [item.commit for item in context.inputs], + "downstream": list(value.downstream), + "harness": context.harness, + "mtype": context.mtype, + } + digest = hashlib.sha256( + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + return FeedRecord( + text=value.update, + layer=context.layer, + agent=context.agent, + session=context.session.id, + output_commit=context.commit, + input_commits=tuple(item.commit for item in context.inputs), + downstream=value.downstream, + harness=context.harness, + mtype=context.mtype, + digest=digest, + ) + + +def _record_key(value: FeedRecord) -> tuple: + return (value.layer, value.agent, value.output_commit, value.session, value.digest) + + def _backward_prompt( layer: str, agent: int, inputs: int, messages: list[str], - workspace_path: str, + parameter_path: str, temp: float, ) -> str: rendered = "\n\n".join( f"## Direction {index}\n\n{message}" for index, message in enumerate(messages) ) return ( - f"Resume node {layer}[{agent}] for backward.\n\n" - "The node root contains two self-contained Git states:\n" - "- statespace/: read-only forward result and input refs\n" - "- workspace/: writable sparse checkout with full model history\n\n" - f"Your writable workspace is workspace/{workspace_path}/. " - "Do not change any other model path.\n\n" + f"Resume temporary episode {layer}[{agent}] for backward.\n\n" + "statespace/ and parameter/ are read-only. workspace/ is temporary episode " + "state. HyTorch will discard it after backward. Do not update your persistent " + f"state now. Propose one update for Parameter {parameter_path}.\n\n" f"# Directional feedback\n\n{rendered}\n\n" - "Use the feedback to improve workspace/. You can leave the workspace " - "unchanged. Commit all workspace changes before ending your turn and leave " - "the repository clean. Each commit becomes part of the global model history. " - "HyTorch combines commits from dependency-ready agents and step() promotes " - "the completed candidate. Do not modify statespace/.\n\n" "Return only one JSON object with this shape:\n" - f'{{"feedback": []}}\n\n' - f"Mutation temperature: {temp}. Use this as the semantic scale of the change.\n" - "Finish the workspace commits and JSON response before ending your turn." + f'{{"update": "", "feedback": []}}\n\n' + f"Mutation temperature: {temp}." ) -def _read_upstream(text: str, count: int) -> list[str]: +def _step_prompt(parameter_path: str, count: int, temp: float) -> str: + return ( + f"Update your persistent native state for Parameter {parameter_path}.\n\n" + "parameter/ is the read-only state before this optimizer step. workspace/ is " + "your writable state. statespace/feeds.json contains all accumulated feed with " + "provenance. Inspect it. Resolve duplicate or conflicting directions. Update " + "workspace/ once. You may change memories, instructions, skills, settings, " + "sessions, databases, or other useful native state. Do not use Git in workspace/. " + f"There are {count} feed records. Mutation temperature: {temp}. Finish the update." + ) + + +def _read_response(text: str, count: int) -> tuple[str, list[str]]: try: value = json.loads(text) except (TypeError, json.JSONDecodeError) as exc: raise RuntimeError( "hytorch.optim.DFM: backward response must be one JSON object" ) from exc - if not isinstance(value, dict) or set(value) != {"feedback"}: + if not isinstance(value, dict) or set(value) != {"update", "feedback"}: raise RuntimeError( - "hytorch.optim.DFM: backward response must contain only 'feedback'" + "hytorch.optim.DFM: backward response must contain 'update' and 'feedback'" ) - feedback = value["feedback"] + feedback = value.get("feedback") if not isinstance(feedback, list) or len(feedback) != count: raise RuntimeError( f"hytorch.optim.DFM: backward response requires {count} feedback strings" @@ -411,22 +475,25 @@ def _read_upstream(text: str, count: int) -> list[str]: raise RuntimeError( "hytorch.optim.DFM: every upstream feedback value must be non-empty text" ) - return [item.strip() for item in feedback] + update = value["update"] + if not isinstance(update, str) or not update.strip(): + raise RuntimeError("hytorch.optim.DFM: owner update must be non-empty text") + return update.strip(), [item.strip() for item in feedback] def _validate_node_root(root: str) -> None: - unexpected = sorted(set(os.listdir(root)) - {"statespace", "workspace"}) + unexpected = sorted( + set(os.listdir(root)) - {"statespace", "parameter", "workspace"} + ) if unexpected: raise ValueError( - "hytorch.optim.DFM: backward changed paths outside the workspace: " + "hytorch.optim.DFM: agent changed paths outside the node state: " + ", ".join(unexpected) ) def _as_text(value: bytes | None) -> str: - if value is None: - return "" - return value.decode("utf-8", errors="replace") + return "" if value is None else value.decode("utf-8", errors="replace") -__all__ = ["DFM"] +__all__ = ["DFM", "FeedRecord"] diff --git a/hytorch/optim/optimizer.py b/hytorch/optim/optimizer.py index ed36f98..22b894b 100644 --- a/hytorch/optim/optimizer.py +++ b/hytorch/optim/optimizer.py @@ -2,10 +2,12 @@ from __future__ import annotations +import os +import shutil from collections.abc import Iterable from ..harness import registered -from ..parameter import Parameter +from ..parameter import Parameter, set_tree_writable class Optimizer: @@ -34,7 +36,7 @@ def zero_feed(self) -> None: for parameter in self.params: parameter.zero_feed() - def _backward(self, output, feedback: str) -> None: + def _backward(self, output, feedback: str, *, retain_graph: bool = False) -> None: raise NotImplementedError def _discard_pending(self) -> None: @@ -50,8 +52,14 @@ def _release(context) -> None: raise RuntimeError( f"hytorch.optim: harness {context.harness!r} is not registered" ) from exc - harness.close(context.session) - context.released = True + try: + harness.close(context.session) + finally: + for path in (context.workspace, context.parameter): + if os.path.isdir(path): + set_tree_writable(path, True) + shutil.rmtree(path, ignore_errors=True) + context.released = True __all__ = ["Optimizer"] diff --git a/hytorch/parameter.py b/hytorch/parameter.py index 1fb2cec..b4e9299 100644 --- a/hytorch/parameter.py +++ b/hytorch/parameter.py @@ -4,7 +4,9 @@ import os import shutil +import stat import subprocess +import tempfile import uuid from collections.abc import Iterator @@ -200,45 +202,64 @@ def copy_tree(source: str, destination: str) -> None: shutil.copytree(source, destination, symlinks=True) -def create_workspace_checkout( - source: str, +def materialize_agent_state( + store: ParameterStore, revision: str, relative_path: str, destination: str, -) -> Repo: - """Create a sparse model checkout with full global workspace history.""" +) -> None: + """Export one agent state without exposing the private model repository.""" if os.path.lexists(destination): set_tree_writable(destination, True) shutil.rmtree(destination) - result = subprocess.run( - [ - "git", - "clone", - "--quiet", - "--no-local", - "--no-checkout", - source, - destination, - ], - capture_output=True, - text=True, - check=False, - ) - if result.returncode != 0: - raise RuntimeError(result.stderr.strip() or "clone workspace store failed") - _git(destination, "config", "user.name", "HyTorch") - _git(destination, "config", "user.email", "hytorch@localhost") - _git(destination, "sparse-checkout", "init", "--no-cone") - _git( - destination, - "sparse-checkout", - "set", - "--no-cone", - f"/{relative_path}/", - ) - _git(destination, "switch", "-c", "hytorch-workspace", revision) - _git(destination, "remote", "remove", "origin") - return Repo.discover(destination) + with tempfile.TemporaryDirectory(prefix="hytorch-agent-export-") as snapshot: + store.repo.export_tree(revision, snapshot) + source = os.path.join(snapshot, relative_path) + if os.path.isdir(source): + shutil.copytree(source, destination, symlinks=True) + else: + os.makedirs(destination) + + +def validate_agent_state( + root: str, + *, + max_files: int = 100_000, + max_bytes: int = 2 * 1024 * 1024 * 1024, +) -> None: + """Validate that private Git can store a complete plain agent state.""" + root = os.path.realpath(root) + file_count = 0 + total_bytes = 0 + for current, directories, files in os.walk(root, followlinks=False): + if ".git" in directories or ".git" in files: + raise ValueError( + "hytorch: native agent state must not contain Git metadata" + ) + for name in directories + files: + path = os.path.join(current, name) + mode = os.lstat(path).st_mode + if stat.S_ISLNK(mode): + target = os.path.realpath(path) + if os.path.commonpath((root, target)) != root: + raise ValueError( + "hytorch: native agent state contains an escaping symlink" + ) + elif not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)): + raise ValueError( + "hytorch: native agent state contains an unsupported special file" + ) + elif stat.S_ISREG(mode): + file_count += 1 + total_bytes += os.lstat(path).st_size + if file_count > max_files: + raise ValueError( + f"hytorch: native agent state exceeds {max_files} files" + ) + if total_bytes > max_bytes: + raise ValueError( + f"hytorch: native agent state exceeds {max_bytes} bytes" + ) def set_tree_writable(root: str, writable: bool) -> None: @@ -259,12 +280,14 @@ def set_tree_writable(root: str, writable: bool) -> None: os.chmod(path, (mode | file_write) if writable else (mode & ~0o222)) -def tree_manifest(root: str) -> dict[str, bytes]: +def tree_manifest(root: str, *, include_git: bool = False) -> dict[str, bytes]: """Return a stable file manifest for mutation-boundary checks.""" result: dict[str, bytes] = {} for current, directories, files in os.walk(root): - directories[:] = sorted(name for name in directories if name != ".git") - for name in sorted(value for value in files if value != ".git"): + directories[:] = sorted( + name for name in directories if include_git or name != ".git" + ) + for name in sorted(value for value in files if include_git or value != ".git"): path = os.path.join(current, name) relative = os.path.relpath(path, root) if os.path.islink(path): @@ -339,6 +362,8 @@ def _git(directory: str, *args: str) -> None: "Parameter", "ParameterView", "copy_tree", + "materialize_agent_state", "set_tree_writable", "tree_manifest", + "validate_agent_state", ] diff --git a/hytorch/pi_harness.py b/hytorch/pi_harness.py index 1b67755..e8bcf6e 100644 --- a/hytorch/pi_harness.py +++ b/hytorch/pi_harness.py @@ -25,7 +25,11 @@ from contextlib import contextmanager from pathlib import Path -from ._environment import agent_environment, docker_environment_file +from ._environment import ( + agent_environment, + command_environment, + docker_environment_file, +) from .harness import Result, Session, Usage _DEFAULT_PROVIDER = "openai-codex" @@ -84,20 +88,18 @@ def start( max_tokens: int | None = None, read_only: tuple[str, ...] = (), ) -> Result: - session_dir = tempfile.mkdtemp(prefix="hytorch-pi-session-") - try: - text, session_id, session_file, _ = self._invoke( - directory, - prompt, - mtype, - session_dir=session_dir, - temperature=temperature, - max_tokens=max_tokens, - read_only=read_only, - ) - except Exception: - shutil.rmtree(session_dir, ignore_errors=True) - raise + _, session_dir = _pi_state_paths(directory) + prior_session = _single_session_file(session_dir) + text, session_id, session_file, _ = self._invoke( + directory, + prompt, + mtype, + session_dir=session_dir, + session_file=prior_session, + temperature=temperature, + max_tokens=max_tokens, + read_only=read_only, + ) return Result( text=text, session=Session( @@ -115,7 +117,7 @@ def resume( temperature: float | None = None, max_tokens: int | None = None, read_only: tuple[str, ...] = (), - ) -> str: + ) -> Result: if session.harness != self.harness_name: raise ValueError( f"hytorch Pi harness cannot resume a {session.harness!r} session" @@ -124,7 +126,7 @@ def resume( raise RuntimeError( f"hytorch Pi harness: saved session {session.id!r} is unavailable" ) - text, resumed_id, _, _ = self._invoke( + text, resumed_id, resumed_file, _ = self._invoke( directory, prompt, mtype, @@ -138,14 +140,20 @@ def resume( raise RuntimeError( f"hytorch Pi harness: resumed session {resumed_id!r}, expected {session.id!r}" ) - return text + return Result( + text=text, + session=Session( + harness=self.harness_name, id=resumed_id, storage=resumed_file + ), + ) def close(self, session: Session) -> None: if session.harness != self.harness_name: raise ValueError( f"hytorch Pi harness cannot close a {session.harness!r} session" ) - shutil.rmtree(os.path.dirname(session.storage), ignore_errors=True) + # Session storage is part of the supplied workspace. Closing a runtime + # must not delete agent state. def _invoke( self, @@ -174,7 +182,11 @@ def _invoke( try: environment = agent_environment() provider = self._provider_for(environment) - with docker_environment_file(values=environment) as environment_path: + agent_state_dir, _ = _pi_state_paths(directory) + with ( + _staged_agent_directory(agent_state_dir) as agent_dir, + docker_environment_file(values=environment) as environment_path, + ): if self.docker: runtime_name = self._docker_image or "hytorch-pi Docker image" if self._uses_remote_docker(): @@ -189,28 +201,28 @@ def _invoke( read_only=read_only, environment_path=environment_path, provider=provider, + agent_dir=agent_dir, ) else: - with _staged_agent_directory() as agent_dir: - args = self._docker_run_args( - directory, - prompt_path, - resolved_model, - session_dir=session_dir, - session_file=session_file, - temperature=temperature, - max_tokens=max_tokens, - read_only=read_only, - environment_path=environment_path, - provider=provider, - agent_dir=agent_dir, - ) - result = subprocess.run( - args, - capture_output=True, - text=True, - check=False, - ) + args = self._docker_run_args( + directory, + prompt_path, + resolved_model, + session_dir=session_dir, + session_file=session_file, + temperature=temperature, + max_tokens=max_tokens, + read_only=read_only, + environment_path=environment_path, + provider=provider, + agent_dir=agent_dir, + ) + result = subprocess.run( + args, + capture_output=True, + text=True, + check=False, + ) else: script_path = os.path.join(runtime_dir, "hytorch-pi.mjs") args = self._host_run_args( @@ -225,14 +237,18 @@ def _invoke( provider=provider, ) runtime_name = script_path - command_environment = dict(os.environ) - command_environment.update(environment) + process_environment = command_environment(values=environment) + process_environment["PI_CODING_AGENT_DIR"] = agent_dir + process_environment["HOME"] = os.path.join( + os.path.dirname(agent_state_dir), "home" + ) + os.makedirs(process_environment["HOME"], exist_ok=True) result = subprocess.run( args, capture_output=True, text=True, check=False, - env=command_environment, + env=process_environment, ) if result.returncode != 0: raise RuntimeError( @@ -431,6 +447,7 @@ def _run_remote_docker( read_only: tuple[str, ...], environment_path: str | None, provider: str, + agent_dir: str, ) -> subprocess.CompletedProcess[str]: if self._docker_image is None: raise RuntimeError("hytorch Pi harness: Docker image was not initialized") @@ -444,6 +461,7 @@ def _run_remote_docker( "statespace": prefix + "-state", "workspace": prefix + "-workspace", "session": prefix + "-session", + "agent": prefix + "-agent", "prompt": prefix + "-prompt", } created: list[str] = [] @@ -470,6 +488,7 @@ def _run_remote_docker( os.path.join(directory, "workspace"), volumes["workspace"] ) self._upload_volume(session_dir, volumes["session"]) + self._upload_volume(agent_dir, volumes["agent"]) self._upload_volume(prompt_dir, volumes["prompt"]) root = os.path.realpath(directory) @@ -492,6 +511,8 @@ def _run_remote_docker( "--mount", f"type=volume,src={volumes['session']},dst=/run/hytorch/session", "--mount", + f"type=volume,src={volumes['agent']},dst=/root/.pi/agent", + "--mount", f"type=volume,src={volumes['prompt']},dst=/run/hytorch/prompt,readonly", ] ) @@ -531,6 +552,7 @@ def _run_remote_docker( if source not in read_only_paths: self._download_volume(volumes[name], source) self._download_volume(volumes["session"], session_dir) + self._download_volume(volumes["agent"], agent_dir) return result finally: shutil.rmtree(prompt_dir, ignore_errors=True) @@ -718,20 +740,83 @@ def _ensure_docker_image(self, directory: str, digest: str) -> None: @contextmanager -def _staged_agent_directory() -> Iterator[str]: - """Give one container an isolated Pi config and preserve newer OAuth data.""" - source = os.path.realpath(os.path.expanduser("~/.pi/agent")) - os.makedirs(source, exist_ok=True) +def _staged_agent_directory(persistent: str) -> Iterator[str]: + """Overlay operator auth on one node-owned Pi profile. + + The staged profile is visible to Pi. Only its non-secret contents return to + the supplied workspace. Refreshed OAuth credentials return to the operator. + """ + persistent = os.path.realpath(persistent) + os.makedirs(persistent, exist_ok=True) + leaked_auth = os.path.join(persistent, "auth.json") + if os.path.lexists(leaked_auth): + os.remove(leaked_auth) + operator = os.path.realpath( + os.path.expanduser(os.environ.get("PI_CODING_AGENT_DIR", "~/.pi/agent")) + ) + os.makedirs(operator, exist_ok=True) parent = tempfile.mkdtemp(prefix="hytorch-pi-agent-") staged = os.path.join(parent, "agent") + shutil.copytree(persistent, staged) + operator_auth = os.path.join(operator, "auth.json") with _auth_lock: - shutil.copytree(source, staged) + if os.path.isfile(operator_auth): + shutil.copy2(operator_auth, os.path.join(staged, "auth.json")) try: yield staged finally: - with _auth_lock: - _promote_newer_auth(os.path.join(staged, "auth.json"), source) - shutil.rmtree(parent, ignore_errors=True) + try: + with _auth_lock: + _promote_newer_auth(os.path.join(staged, "auth.json"), operator) + _replace_agent_profile(staged, persistent) + finally: + shutil.rmtree(parent, ignore_errors=True) + + +def _pi_state_paths(directory: str) -> tuple[str, str]: + workspace = os.path.realpath(os.path.join(directory, "workspace")) + root = os.path.realpath(directory) + if os.path.commonpath((root, workspace)) != root or not os.path.isdir(workspace): + raise RuntimeError("hytorch Pi harness: node workspace is unavailable") + state = os.path.join(workspace, ".pi") + agent = os.path.join(state, "agent") + sessions = os.path.join(state, "sessions") + os.makedirs(agent, exist_ok=True) + os.makedirs(sessions, exist_ok=True) + return agent, sessions + + +def _single_session_file(session_dir: str) -> str | None: + sessions = sorted( + os.path.join(session_dir, name) + for name in os.listdir(session_dir) + if name.endswith(".jsonl") and os.path.isfile(os.path.join(session_dir, name)) + ) + if len(sessions) > 1: + raise RuntimeError( + "hytorch Pi harness: node state contains more than one root session" + ) + return sessions[0] if sessions else None + + +def _replace_agent_profile(candidate: str, destination: str) -> None: + """Replace a candidate profile without copying its auth file.""" + parent = os.path.dirname(destination) + staged = tempfile.mkdtemp(prefix=".hytorch-pi-profile-", dir=parent) + try: + for name in os.listdir(candidate): + if name == "auth.json": + continue + source = os.path.join(candidate, name) + target = os.path.join(staged, name) + if os.path.isdir(source) and not os.path.islink(source): + shutil.copytree(source, target, symlinks=True) + else: + shutil.copy2(source, target, follow_symlinks=False) + shutil.rmtree(destination) + os.replace(staged, destination) + finally: + shutil.rmtree(staged, ignore_errors=True) def _promote_newer_auth(candidate: str, destination_dir: str) -> None: diff --git a/hytorch/prime_harness.py b/hytorch/prime_harness.py new file mode 100644 index 0000000..44405bd --- /dev/null +++ b/hytorch/prime_harness.py @@ -0,0 +1,438 @@ +"""Prime Agent runtime with node-local persistent native state.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tempfile +import threading +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from pathlib import Path + +from ._environment import command_environment +from ._native_view import native_node_view +from .harness import Result, Session, Usage + +_DEFAULT_PROVIDER = "openai-codex" +_DEFAULT_MODEL = "gpt-5.6-terra" +_TRANSIENT_PROFILE_ENTRIES = { + "auth.json", + "daemon-update-restart.json", + "daemon-update-restarts", + "daemon-workers", + "kernel-venv", +} +_auth_lock = threading.Lock() + + +class PrimeRuntime: + """Run one persistent Prime Agent session through its JSON interface.""" + + def __init__( + self, + provider: str | None = None, + model: str = _DEFAULT_MODEL, + binary: str = "prime-agent", + *, + harness_name: str = "prime-agent", + runner: Callable[..., subprocess.CompletedProcess[str]] | None = None, + ) -> None: + self.provider = provider or _DEFAULT_PROVIDER + self._provider_explicit = bool(provider) + self.model = model or _DEFAULT_MODEL + self.binary = binary or "prime-agent" + self.harness_name = harness_name + self._runner = runner + self._usage = Usage() + self._usage_lock = threading.Lock() + + def usage(self) -> Usage: + with self._usage_lock: + return self._usage + + def start( + self, + directory: str, + prompt: str, + mtype: str | None, + *, + temperature: float | None = None, + max_tokens: int | None = None, + read_only: tuple[str, ...] = (), + ) -> Result: + _, session_dir = _prime_state_paths(directory) + session_file = _single_session_file(session_dir) + return self._invoke( + directory, + prompt, + mtype, + session_dir=session_dir, + session_file=session_file, + temperature=temperature, + max_tokens=max_tokens, + read_only=read_only, + ) + + def resume( + self, + session: Session, + directory: str, + prompt: str, + mtype: str | None, + *, + temperature: float | None = None, + max_tokens: int | None = None, + read_only: tuple[str, ...] = (), + ) -> Result: + if session.harness != self.harness_name: + raise ValueError( + "hytorch Prime Agent harness cannot resume " + f"a {session.harness!r} session" + ) + if not os.path.isfile(session.storage): + raise RuntimeError( + "hytorch Prime Agent harness: saved session " + f"{session.id!r} is unavailable" + ) + result = self._invoke( + directory, + prompt, + mtype, + session_dir=os.path.dirname(session.storage), + session_file=session.storage, + temperature=temperature, + max_tokens=max_tokens, + read_only=read_only, + ) + if result.session.id != session.id: + raise RuntimeError( + "hytorch Prime Agent harness: resumed session " + f"{result.session.id!r}, expected {session.id!r}" + ) + return result + + def close(self, session: Session) -> None: + if session.harness != self.harness_name: + raise ValueError( + "hytorch Prime Agent harness cannot close " + f"a {session.harness!r} session" + ) + # Prime's headless worker has exited. Its session and artifacts remain + # in the supplied workspace. + + def _invoke( + self, + directory: str, + prompt: str, + mtype: str | None, + *, + session_dir: str, + session_file: str | None, + temperature: float | None, + max_tokens: int | None, + read_only: tuple[str, ...], + ) -> Result: + _validate_sampling(temperature, max_tokens) + _validate_read_only(directory, read_only) + profile_dir, _ = _prime_state_paths(directory) + environment = command_environment() + provider = self._provider_for(environment) + + with native_node_view(directory) as execution_root: + with _staged_prime_profile(profile_dir) as staged_profile: + profile_link = os.path.join(execution_root, "prime-profile") + os.symlink(staged_profile, profile_link, target_is_directory=True) + stable_session_dir = os.path.join( + execution_root, "workspace", ".prime", "sessions" + ) + environment.update( + { + "PI_SKIP_VERSION_CHECK": "1", + "PRIME_AGENT_CODING_AGENT_DIR": profile_link, + "PRIME_AGENT_SESSION_DIR": stable_session_dir, + "HOME": os.path.join( + execution_root, "workspace", ".prime", "home" + ), + } + ) + os.makedirs( + os.path.join(os.path.dirname(profile_dir), "home"), exist_ok=True + ) + args = [ + self.binary, + "--mode", + "json", + "--cwd", + execution_root, + "--session-dir", + stable_session_dir, + "--provider", + provider, + "--model", + mtype or self.model, + ] + if session_file is not None: + args.extend( + [ + "--resume", + os.path.join( + stable_session_dir, os.path.basename(session_file) + ), + ] + ) + args.extend(["--", prompt]) + try: + try: + runner = self._runner or subprocess.run + completed = runner( + args, + cwd=execution_root, + capture_output=True, + text=True, + check=False, + env=environment, + ) + except FileNotFoundError as exc: + raise RuntimeError( + "hytorch Prime Agent harness: executable " + f"{self.binary!r} is unavailable" + ) from exc + finally: + if os.path.lexists(profile_link): + os.remove(profile_link) + + if completed.returncode != 0: + raise RuntimeError( + "hytorch Prime Agent harness: exit " + f"{completed.returncode}: {completed.stderr.strip()}" + ) + text, session_id, usage = _parse_json_events(completed.stdout) + resolved_file = session_file or os.path.join(session_dir, f"{session_id}.jsonl") + if not os.path.isfile(resolved_file): + matches = [ + path + for path in ( + os.path.join(session_dir, name) for name in os.listdir(session_dir) + ) + if path.endswith(".jsonl") and os.path.isfile(path) + ] + if len(matches) == 1: + resolved_file = matches[0] + else: + raise RuntimeError( + "hytorch Prime Agent harness: persisted session file is unavailable" + ) + with self._usage_lock: + self._usage = Usage( + input_tokens=self._usage.input_tokens + usage.input_tokens, + output_tokens=self._usage.output_tokens + usage.output_tokens, + cache_read_tokens=( + self._usage.cache_read_tokens + usage.cache_read_tokens + ), + cache_write_tokens=( + self._usage.cache_write_tokens + usage.cache_write_tokens + ), + ) + return Result( + text=text, + session=Session( + harness=self.harness_name, + id=session_id, + storage=os.path.realpath(resolved_file), + ), + ) + + def _provider_for(self, environment: dict[str, str]) -> str: + if not self._provider_explicit and "OPENAI_API_KEY" in environment: + return "openai" + return self.provider + + +def _prime_state_paths(directory: str) -> tuple[str, str]: + root = os.path.realpath(directory) + workspace = os.path.realpath(os.path.join(root, "workspace")) + if os.path.commonpath((root, workspace)) != root or not os.path.isdir(workspace): + raise RuntimeError("hytorch Prime Agent harness: node workspace is unavailable") + state = os.path.join(workspace, ".prime") + profile = os.path.join(state, "agent") + sessions = os.path.join(state, "sessions") + os.makedirs(profile, exist_ok=True) + os.makedirs(sessions, exist_ok=True) + return profile, sessions + + +def _single_session_file(session_dir: str) -> str | None: + sessions = sorted( + os.path.join(session_dir, name) + for name in os.listdir(session_dir) + if name.endswith(".jsonl") and os.path.isfile(os.path.join(session_dir, name)) + ) + if len(sessions) > 1: + raise RuntimeError( + "hytorch Prime Agent harness: node state contains more than one root session" + ) + return sessions[0] if sessions else None + + +@contextmanager +def _staged_prime_profile(persistent: str) -> Iterator[str]: + persistent = os.path.realpath(persistent) + os.makedirs(persistent, exist_ok=True) + for name in _TRANSIENT_PROFILE_ENTRIES: + path = os.path.join(persistent, name) + if os.path.isdir(path) and not os.path.islink(path): + shutil.rmtree(path) + elif os.path.lexists(path): + os.remove(path) + + operator = os.path.realpath( + os.path.expanduser( + os.environ.get("PRIME_AGENT_CODING_AGENT_DIR", "~/.prime/agent") + ) + ) + os.makedirs(operator, exist_ok=True) + parent = tempfile.mkdtemp(prefix="hytorch-prime-agent-") + staged = os.path.join(parent, "agent") + shutil.copytree(persistent, staged) + operator_auth = os.path.join(operator, "auth.json") + with _auth_lock: + if os.path.isfile(operator_auth): + shutil.copy2(operator_auth, os.path.join(staged, "auth.json")) + try: + yield staged + finally: + try: + with _auth_lock: + _promote_newer_auth(os.path.join(staged, "auth.json"), operator) + _replace_profile(staged, persistent) + finally: + shutil.rmtree(parent, ignore_errors=True) + + +def _replace_profile(candidate: str, destination: str) -> None: + parent = os.path.dirname(destination) + staged = tempfile.mkdtemp(prefix=".hytorch-prime-profile-", dir=parent) + try: + for name in os.listdir(candidate): + if name in _TRANSIENT_PROFILE_ENTRIES: + continue + source = os.path.join(candidate, name) + target = os.path.join(staged, name) + if os.path.isdir(source) and not os.path.islink(source): + shutil.copytree(source, target, symlinks=True) + else: + shutil.copy2(source, target, follow_symlinks=False) + shutil.rmtree(destination) + os.replace(staged, destination) + finally: + shutil.rmtree(staged, ignore_errors=True) + + +def _promote_newer_auth(candidate: str, destination_dir: str) -> None: + if not os.path.isfile(candidate): + return + destination = os.path.join(destination_dir, "auth.json") + try: + candidate_data = json.loads(Path(candidate).read_text(encoding="utf-8")) + current_data = ( + json.loads(Path(destination).read_text(encoding="utf-8")) + if os.path.isfile(destination) + else {} + ) + except (OSError, json.JSONDecodeError): + return + changed = False + for provider, value in candidate_data.items(): + if not isinstance(value, dict): + continue + current = current_data.get(provider) + candidate_expiry = value.get("expires", 0) + current_expiry = current.get("expires", 0) if isinstance(current, dict) else 0 + if provider not in current_data or candidate_expiry > current_expiry: + current_data[provider] = value + changed = True + if changed: + os.makedirs(destination_dir, exist_ok=True) + Path(destination).write_text( + json.dumps(current_data, indent=2) + "\n", encoding="utf-8" + ) + + +def _parse_json_events(output: str) -> tuple[str, str, Usage]: + session_id = "" + final_text = "" + usage = Usage() + for line in output.splitlines(): + if not line.strip(): + continue + try: + event = json.loads(line) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"hytorch Prime Agent harness: invalid JSON event: {line}" + ) from exc + if event.get("type") == "session" and isinstance(event.get("id"), str): + session_id = event["id"] + if event.get("type") != "message_end": + continue + message = event.get("message") + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + content = message.get("content", []) + if isinstance(content, list): + text = "".join( + block.get("text", "") + for block in content + if isinstance(block, dict) and block.get("type") == "text" + ) + if text: + final_text = text + raw = message.get("usage", {}) + if isinstance(raw, dict): + usage = Usage( + input_tokens=usage.input_tokens + int(raw.get("input", 0)), + output_tokens=usage.output_tokens + int(raw.get("output", 0)), + cache_read_tokens=( + usage.cache_read_tokens + int(raw.get("cacheRead", 0)) + ), + cache_write_tokens=( + usage.cache_write_tokens + int(raw.get("cacheWrite", 0)) + ), + ) + if not session_id or not final_text: + raise RuntimeError("hytorch Prime Agent harness: incomplete JSON event stream") + return final_text.strip(), session_id, usage + + +def _validate_read_only(directory: str, read_only: tuple[str, ...]) -> None: + root = os.path.realpath(directory) + for path in read_only: + if os.path.commonpath((root, os.path.realpath(path))) != root: + raise ValueError( + "hytorch Prime Agent harness: read-only path escaped the node root" + ) + + +def _validate_sampling(temperature: float | None, max_tokens: int | None) -> None: + if temperature is not None and ( + not isinstance(temperature, (int, float)) + or isinstance(temperature, bool) + or temperature < 0 + ): + raise ValueError( + "hytorch Prime Agent harness: temperature must be non-negative or None" + ) + if max_tokens is not None and ( + not isinstance(max_tokens, int) + or isinstance(max_tokens, bool) + or max_tokens <= 0 + ): + raise ValueError( + "hytorch Prime Agent harness: max_tokens must be positive or None" + ) + + +__all__ = ["PrimeRuntime"] diff --git a/hytorch/runtime/Dockerfile b/hytorch/runtime/Dockerfile index c8f0910..8c13a65 100644 --- a/hytorch/runtime/Dockerfile +++ b/hytorch/runtime/Dockerfile @@ -1,7 +1,11 @@ FROM node:22-bookworm-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates git \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + python3 \ + python-is-python3 \ && rm -rf /var/lib/apt/lists/* WORKDIR /opt/hytorch-pi diff --git a/pyproject.toml b/pyproject.toml index 6ea6f88..3e54ae1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,3 +50,6 @@ target-version = "py311" [tool.ruff.lint] select = ["E4", "E7", "E9", "F", "I"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_backward.py b/tests/test_backward.py index 9b9389e..911a616 100644 --- a/tests/test_backward.py +++ b/tests/test_backward.py @@ -7,7 +7,6 @@ commit_agent_changes, find_agent_workspace, merge_agent_inputs, - run_git, ) import hytorch @@ -22,15 +21,24 @@ def __init__(self, name="direction"): self.closed = [] self.received = {} self.max_tokens = None + self.owner_reductions = [] def start(self, directory, prompt, mtype, **kwargs): session = hytorch.harness.Session(self.name, f"session-{len(self.started)}", "") self.started.append(session) + workspace = find_agent_workspace(os.path.join(directory, "workspace")) + if prompt.startswith("Update your persistent native state"): + self.owner_reductions.append(prompt) + with open(os.path.join(workspace, "owner-update.md"), "w") as file: + file.write("Reduced all accumulated feed.\n") + return hytorch.harness.Result("finished owner update", session) statespace = os.path.join(directory, "statespace") merge_agent_inputs(statespace) with open(os.path.join(statespace, "answer.txt"), "a") as file: file.write(session.id + "\n") commit_agent_changes(statespace, "agent: finish forward") + with open(os.path.join(workspace, "forward-memory.md"), "w") as file: + file.write("This session completed a forward turn.\n") return hytorch.harness.Result("finished forward", session) def resume(self, session, directory, prompt, mtype, **kwargs): @@ -41,7 +49,6 @@ def resume(self, session, directory, prompt, mtype, **kwargs): selected = find_agent_workspace(workspace) with open(os.path.join(selected, f"update-{session.id}.md"), "w") as file: file.write("Apply the received direction in future passes.\n") - commit_agent_changes(workspace, "agent: update workspace") input_count = len( os.listdir( os.path.join( @@ -49,19 +56,101 @@ def resume(self, session, directory, prompt, mtype, **kwargs): ) ) ) - return json.dumps( - { - "feedback": [ - f"Improve input {index} for {session.id}." - for index in range(input_count) - ] - } + return hytorch.harness.Result( + json.dumps( + { + "update": "Apply the received direction in future passes.", + "feedback": [ + f"Improve input {index} for {session.id}." + for index in range(input_count) + ], + } + ), + session, ) def close(self, session): self.closed.append(session) +def test_two_forwards_accumulate_feed_and_step_reduces_owner_once(new_repo): + harness = hytorch.harness.register(DirectionHarness("accumulate")) + model = OneNode().to(harness) + optimizer = hytorch.optim.DFM(model.parameters()) + + first = model(hytorch.space(new_repo.root, harness=harness)) + second = model(hytorch.space(new_repo.root, harness=harness)) + hytorch.Loss(first, "Keep exact evidence.").backward() + hytorch.Loss(second, "Use a smaller proof.").backward() + + assert model.layer.weight[0].feed == ( + "Apply the received direction in future passes.", + "Apply the received direction in future passes.", + ) + records = next(iter(optimizer._records.values())) + assert len(records) == 2 + assert len({record.digest for record in records}) == 2 + + optimizer.step() + + assert len(harness.owner_reductions) == 1 + + +def test_backward_retain_graph_matches_pytorch_lifecycle(new_repo): + harness = hytorch.harness.register(DirectionHarness("retain")) + model = OneNode().to(harness) + optimizer = hytorch.optim.DFM(model.parameters()) + output = model(hytorch.space(new_repo.root, harness=harness)) + + hytorch.Loss(output, "Check correctness.").backward(retain_graph=True) + assert not output.feed_fn.consumed + assert not output.feed_fn.released + hytorch.Loss(output, "Check efficiency.").backward() + assert output.feed_fn.consumed + assert output.feed_fn.released + assert len(model.layer.weight[0].feed) == 2 + + with pytest.raises(RuntimeError, match="second time"): + hytorch.Loss(output, "Run again.").backward() + optimizer.step() + + +def test_step_is_atomic_and_retains_feed_after_reducer_failure(new_repo): + class FailingReducer(DirectionHarness): + def start(self, directory, prompt, mtype, **kwargs): + if prompt.startswith( + "Update your persistent native state for Parameter layers/layer/1" + ): + raise RuntimeError("reducer failed") + return super().start(directory, prompt, mtype, **kwargs) + + harness = hytorch.harness.register(FailingReducer("atomic")) + model = mn.Linear(1, 2, bias="Be exact.") + + class TwoOwners(mn.Module): + def __init__(self, layer): + super().__init__() + self.layer = layer + + def forward(self, value): + return self.layer(value) + + network = TwoOwners(model).to(harness) + optimizer = hytorch.optim.DFM(network.parameters()) + outputs = network(hytorch.space(new_repo.root, harness=harness)) + before = model.weight[0].revision + hytorch.Loss(outputs[0], "Improve owner zero.").backward() + hytorch.Loss(outputs[1], "Improve owner one.").backward() + + with pytest.raises(RuntimeError, match="reducer failed"): + optimizer.step() + + assert model.weight[0].revision == before + assert model.weight[0].feed is not None + assert model.weight[1].feed is not None + optimizer.zero_feed() + + class OneNode(mn.Module): def __init__(self): super().__init__() @@ -85,7 +174,7 @@ def test_loss_contains_only_output_and_directional_feedback(new_repo): hytorch.Loss(output, score=0.0, critique="old API") -def test_backward_updates_candidate_and_step_promotes_it(new_repo): +def test_backward_accumulates_feed_and_step_updates_parameter(new_repo): harness = hytorch.harness.register(DirectionHarness("promote")) model = OneNode().to(harness) optimizer = hytorch.optim.DFM(model.parameters(), temp=0.4) @@ -98,23 +187,24 @@ def test_backward_updates_candidate_and_step_promotes_it(new_repo): assert not os.path.exists( os.path.join(model.layer.weight[0].path, "update-session-0.md") ) + assert not os.path.exists( + os.path.join(model.layer.weight[0].path, "forward-memory.md") + ) assert output.feed_fn.released assert {session.id for session in harness.closed} == { session.id for session in harness.started } - assert model.layer.weight[0].feed == ("Use a stricter validation rule.",) - agent_commit = run_git(output.feed_fn.workspace, "rev-parse", "HEAD") - history = run_git(output.feed_fn.workspace, "log", "--format=%s") - assert "hytorch: initialize meta-network workspaces" in history + assert model.layer.weight[0].feed == ( + "Apply the received direction in future passes.", + ) + assert not os.path.exists(output.feed_fn.workspace) optimizer.step() assert model.layer.weight[0].revision != before - assert model.layer.weight._store.repo.is_ancestor( - agent_commit, model.layer.weight[0].revision - ) - assert os.path.isfile( - os.path.join(model.layer.weight[0].path, "update-session-0.md") + assert os.path.isfile(os.path.join(model.layer.weight[0].path, "owner-update.md")) + assert not os.path.exists( + os.path.join(model.layer.weight[0].path, "forward-memory.md") ) @@ -138,16 +228,42 @@ def forward(self, value): hytorch.Loss(output, "Prefer the smallest correct solution.").backward() - assert model.final.weight[0].feed == ("Prefer the smallest correct solution.",) + assert model.final.weight[0].feed == ( + "Apply the received direction in future passes.", + ) assert len(model.split.weight[0].feed) == 1 assert len(model.split.weight[1].feed) == 1 - assert len(model.first.weight[0].feed) == 2 + assert len(model.first.weight[0].feed) == 1 assert {session.id for session in harness.closed} == { session.id for session in harness.started } optimizer.step() +def test_frozen_intermediate_parameter_propagates_without_feed(new_repo): + class FrozenMiddle(mn.Module): + def __init__(self): + super().__init__() + self.first = mn.Linear(1, 1, bias="Learn.") + self.middle = mn.Linear(1, 1, bias="Stay fixed.") + self.middle.weight.requires_feed = False + + def forward(self, value): + return self.middle(self.first(value))[0] + + harness = hytorch.harness.register(DirectionHarness("frozen")) + model = FrozenMiddle().to(harness) + optimizer = hytorch.optim.DFM(model.parameters()) + output = model(hytorch.space(new_repo.root, harness=harness)) + + hytorch.Loss(output, "Improve the learned input.").backward() + + assert model.middle.weight[0].feed is None + assert model.first.weight[0].feed is not None + optimizer.step() + assert len(harness.owner_reductions) == 1 + + def test_backward_runs_ready_distinct_workspaces_in_parallel(new_repo): class ParallelDirection(DirectionHarness): def __init__(self): @@ -184,7 +300,7 @@ def forward(self, value): optimizer.step() for view in model.split.weight: - assert any(name.startswith("update-session-") for name in os.listdir(view.path)) + assert os.path.isfile(os.path.join(view.path, "owner-update.md")) def test_zero_feed_discards_unpromoted_candidate(new_repo): @@ -205,8 +321,14 @@ def test_zero_feed_discards_unpromoted_candidate(new_repo): def test_backward_allows_an_unchanged_workspace(new_repo): class NoMutation(DirectionHarness): def resume(self, session, directory, prompt, mtype, **kwargs): - return json.dumps( - {"feedback": ["Preserve more useful detail in the input."]} + return hytorch.harness.Result( + json.dumps( + { + "update": "Keep the useful behavior.", + "feedback": ["Preserve more useful detail in the input."], + } + ), + session, ) harness = hytorch.harness.register(NoMutation("no-mutation")) @@ -218,7 +340,8 @@ def resume(self, session, directory, prompt, mtype, **kwargs): hytorch.Loss(output, "Keep the current behavior.").backward() optimizer.step() - assert model.layer.weight[0].revision == before + assert model.layer.weight[0].revision != before + assert os.path.isfile(os.path.join(model.layer.weight[0].path, "owner-update.md")) assert output.feed_fn.released @@ -259,8 +382,10 @@ def resume(self, session, directory, prompt, mtype, **kwargs): selected = find_agent_workspace(workspace) with open(os.path.join(selected, "changed.md"), "w") as file: file.write("changed\n") - commit_agent_changes(workspace, "agent: incomplete response") - return json.dumps({"feedback": []}) + return hytorch.harness.Result( + json.dumps({"update": "Use exact evidence.", "feedback": []}), + session, + ) harness = hytorch.harness.register(MissingFeedback("missing-feedback")) model = OneNode().to(harness) @@ -269,3 +394,39 @@ def resume(self, session, directory, prompt, mtype, **kwargs): with pytest.raises(RuntimeError, match="requires 1 feedback strings"): hytorch.Loss(output, "Explain the required input change.").backward() + + +def test_backward_accepts_a_rotated_native_session_tip(new_repo): + class CompactingHarness(DirectionHarness): + def resume(self, session, directory, prompt, mtype, **kwargs): + result = super().resume(session, directory, prompt, mtype, **kwargs) + rotated = hytorch.harness.Session( + session.harness, session.id + "-compacted", session.storage + ) + return hytorch.harness.Result(result.text, rotated) + + harness = hytorch.harness.register(CompactingHarness("compacting")) + model = OneNode().to(harness) + optimizer = hytorch.optim.DFM(model.parameters()) + output = model(hytorch.space(new_repo.root, harness=harness)) + + hytorch.Loss(output, "Retain the useful context.").backward() + + assert harness.closed[-1].id.endswith("-compacted") + optimizer.step() + + +def test_backward_rejects_git_metadata_in_native_agent_state(new_repo): + class NestedGitHarness(DirectionHarness): + def resume(self, session, directory, prompt, mtype, **kwargs): + result = super().resume(session, directory, prompt, mtype, **kwargs) + os.makedirs(os.path.join(directory, "workspace", ".git")) + return result + + harness = hytorch.harness.register(NestedGitHarness("nested-git")) + model = OneNode().to(harness) + hytorch.optim.DFM(model.parameters()) + output = model(hytorch.space(new_repo.root, harness=harness)) + + with pytest.raises(ValueError, match="must not contain Git metadata"): + hytorch.Loss(output, "Keep state portable.").backward() diff --git a/tests/test_codex_claude_harness.py b/tests/test_codex_claude_harness.py new file mode 100644 index 0000000..759f2e5 --- /dev/null +++ b/tests/test_codex_claude_harness.py @@ -0,0 +1,281 @@ +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from hytorch.claude_harness import ClaudeCodeHarness +from hytorch.codex_harness import CodexHarness +from hytorch.harness import Result, Session, Usage + + +def _node(tmp_path: Path) -> Path: + root = tmp_path / "node" + (root / "workspace").mkdir(parents=True) + (root / "statespace").mkdir() + return root + + +def _codex_output(session_id: str, text: str = "done") -> str: + events = [ + {"type": "thread.started", "thread_id": session_id}, + { + "type": "item.completed", + "item": {"type": "agent_message", "text": text}, + }, + { + "type": "turn.completed", + "usage": { + "input_tokens": 10, + "cached_input_tokens": 4, + "output_tokens": 3, + }, + }, + ] + return "\n".join(json.dumps(event) for event in events) + + +def _claude_output(session_id: str, text: str = "done") -> str: + return json.dumps( + { + "type": "result", + "is_error": False, + "result": text, + "session_id": session_id, + "usage": { + "input_tokens": 11, + "output_tokens": 5, + "cache_read_input_tokens": 6, + "cache_creation_input_tokens": 2, + }, + } + ) + + +def test_codex_start_uses_isolated_native_home_and_machine_output(tmp_path): + root = _node(tmp_path) + calls = [] + + def runner(args, **kwargs): + assert os.path.realpath(Path(kwargs["cwd"]) / "statespace") == str( + (root / "statespace").resolve() + ) + assert os.path.realpath(Path(kwargs["cwd"]) / "workspace") == str( + (root / "workspace").resolve() + ) + calls.append((args, kwargs)) + return subprocess.CompletedProcess(args, 0, _codex_output("codex-one"), "") + + harness = CodexHarness(runner=runner, environment={"CODEX_API_KEY": "secret"}) + result = harness.start(str(root), "work", "gpt-test") + + assert isinstance(result, Result) + assert result.text == "done" + assert result.session == Session( + "codex", "codex-one", str(root / "workspace" / ".hytorch" / "codex") + ) + args, kwargs = calls[0] + stable = Path(args[9]) + assert args == [ + "codex", + "exec", + "--json", + "--model", + "gpt-test", + "--skip-git-repo-check", + "--sandbox", + "danger-full-access", + "--cd", + str(stable), + "-", + ] + assert kwargs["cwd"] == stable + assert kwargs["input"] == "work" + assert kwargs["env"]["CODEX_HOME"] == str( + stable / "workspace" / ".hytorch" / "codex" + ) + assert harness.usage() == Usage(10, 3, 4, 0) + + +def test_codex_start_continues_native_candidate_session(tmp_path): + root = _node(tmp_path) + session_file = root / "workspace" / ".hytorch" / "codex" / "sessions" / "run.jsonl" + session_file.parent.mkdir(parents=True) + session_file.write_text("native session\n") + calls = [] + + def runner(args, **kwargs): + calls.append(args) + return subprocess.CompletedProcess(args, 0, _codex_output("codex-old"), "") + + harness = CodexHarness(runner=runner) + result = harness.start(str(root), "next epoch", None) + + assert result.session.id == "codex-old" + assert calls[0][-4:] == ["resume", "--last", "--all", "-"] + + +def test_codex_resume_returns_result_and_close_preserves_state(tmp_path): + root = _node(tmp_path) + marker = root / "workspace" / ".hytorch" / "codex" / "memory.db" + marker.parent.mkdir(parents=True) + marker.write_text("memory") + calls = [] + + def runner(args, **kwargs): + calls.append(args) + return subprocess.CompletedProcess(args, 0, _codex_output("codex-one"), "") + + harness = CodexHarness(runner=runner) + session = Session("codex", "codex-one", str(marker.parent)) + result = harness.resume(session, str(root), "feedback", None) + harness.close(result.session) + + assert isinstance(result, Result) + assert calls[0][-3:] == ["resume", "codex-one", "-"] + assert marker.read_text() == "memory" + + +def test_codex_auth_overlay_is_not_persistent(tmp_path): + root = _node(tmp_path) + source = tmp_path / "auth.json" + source.write_text('{"token":"secret"}') + observed = [] + + def runner(args, **kwargs): + target = root / "workspace" / ".hytorch" / "codex" / "auth.json" + observed.append(target.read_text()) + return subprocess.CompletedProcess(args, 0, _codex_output("codex-one"), "") + + harness = CodexHarness(auth_file=str(source), runner=runner) + harness.start(str(root), "work", None) + + assert observed == ['{"token":"secret"}'] + assert not (root / "workspace" / ".hytorch" / "codex" / "auth.json").exists() + + +def test_claude_start_uses_isolated_native_home_and_machine_output(tmp_path): + root = _node(tmp_path) + calls = [] + + def runner(args, **kwargs): + assert os.path.realpath(Path(kwargs["cwd"]) / "statespace") == str( + (root / "statespace").resolve() + ) + calls.append((args, kwargs)) + return subprocess.CompletedProcess( + args, 0, _claude_output("00000000-0000-4000-8000-000000000001"), "" + ) + + harness = ClaudeCodeHarness( + runner=runner, environment={"ANTHROPIC_API_KEY": "secret"} + ) + result = harness.start(str(root), "work", "claude-test") + + assert isinstance(result, Result) + assert result.text == "done" + assert result.session == Session( + "claude-code", + "00000000-0000-4000-8000-000000000001", + str(root / "workspace" / ".hytorch" / "claude"), + ) + args, kwargs = calls[0] + assert args == [ + "claude", + "--print", + "--output-format", + "json", + "--model", + "claude-test", + "--permission-mode", + "bypassPermissions", + "--dangerously-skip-permissions", + ] + stable = Path(kwargs["cwd"]) + assert stable.parent.name == "hytorch-native" + assert kwargs["input"] == "work" + assert kwargs["env"]["CLAUDE_CONFIG_DIR"] == str( + stable / "workspace" / ".hytorch" / "claude" + ) + assert harness.usage() == Usage(11, 5, 6, 2) + + +def test_claude_start_breaks_equal_session_mtimes_deterministically(tmp_path): + root = _node(tmp_path) + old_id = "00000000-0000-4000-8000-000000000001" + new_id = "00000000-0000-4000-8000-000000000002" + project = root / "workspace" / ".hytorch" / "claude" / "projects" / "node" + project.mkdir(parents=True) + old = project / f"{old_id}.jsonl" + new = project / f"{new_id}.jsonl" + old.write_text("old") + new.write_text("new") + old.touch() + new.touch() + timestamp = max(old.stat().st_mtime_ns, new.stat().st_mtime_ns) + os.utime(old, ns=(timestamp, timestamp)) + os.utime(new, ns=(timestamp, timestamp)) + calls = [] + + def runner(args, **kwargs): + calls.append(args) + return subprocess.CompletedProcess(args, 0, _claude_output(new_id), "") + + harness = ClaudeCodeHarness(runner=runner) + result = harness.start(str(root), "next epoch", None) + + assert result.session.id == new_id + assert calls[0][-2:] == ["--resume", new_id] + + +def test_claude_resume_and_auth_overlay_preserve_native_state(tmp_path): + root = _node(tmp_path) + session_id = "00000000-0000-4000-8000-000000000001" + source = tmp_path / "credentials.json" + source.write_text('{"oauth":"secret"}') + marker = root / "workspace" / ".hytorch" / "claude" / "memory.md" + marker.parent.mkdir(parents=True) + marker.write_text("memory") + observed = [] + + def runner(args, **kwargs): + target = marker.parent / ".credentials.json" + observed.append((args, target.read_text())) + return subprocess.CompletedProcess(args, 0, _claude_output(session_id), "") + + harness = ClaudeCodeHarness(auth_file=str(source), runner=runner) + result = harness.resume( + Session("claude-code", session_id, str(marker.parent)), + str(root), + "feedback", + None, + ) + harness.close(result.session) + + assert isinstance(result, Result) + assert observed[0][0][-2:] == ["--resume", session_id] + assert observed[0][1] == '{"oauth":"secret"}' + assert not (marker.parent / ".credentials.json").exists() + assert marker.read_text() == "memory" + + +@pytest.mark.parametrize( + ("harness", "output"), + [ + (CodexHarness(), _codex_output("codex-one")), + ( + ClaudeCodeHarness(), + _claude_output("00000000-0000-4000-8000-000000000001"), + ), + ], +) +def test_local_cli_harnesses_tolerate_generic_sampling(harness, output, tmp_path): + root = _node(tmp_path) + harness._runner = lambda args, **kwargs: subprocess.CompletedProcess( + args, 0, output, "" + ) + + result = harness.start(str(root), "work", None, temperature=0.4, max_tokens=512) + + assert result.text == "done" diff --git a/tests/test_environment.py b/tests/test_environment.py index a3d57b4..bef0a11 100644 --- a/tests/test_environment.py +++ b/tests/test_environment.py @@ -4,7 +4,18 @@ import pytest -from hytorch._environment import agent_environment, docker_environment_file +from hytorch._environment import ( + KNOWN_PROVIDER_KEYS, + agent_environment, + command_environment, + docker_environment_file, +) + + +@pytest.fixture(autouse=True) +def _clear_provider_environment(monkeypatch): + for name in KNOWN_PROVIDER_KEYS: + monkeypatch.delenv(name, raising=False) def _project(tmp_path): @@ -64,6 +75,21 @@ def test_docker_environment_file_is_private_and_temporary(tmp_path, monkeypatch) assert not os.path.exists(path) +def test_command_environment_does_not_leak_undeclared_shell_values( + tmp_path, monkeypatch +): + root = _project(tmp_path) + monkeypatch.chdir(root) + monkeypatch.setenv("UNRELATED_SECRET", "do-not-forward") + monkeypatch.setenv("OPENAI_API_KEY", "provider-key") + + values = command_environment(values={"HARNESS_SETTING": "enabled"}) + + assert values["OPENAI_API_KEY"] == "provider-key" + assert values["HARNESS_SETTING"] == "enabled" + assert "UNRELATED_SECRET" not in values + + def test_missing_explicit_environment_file_fails(tmp_path, monkeypatch): root = _project(tmp_path) monkeypatch.setenv("HYTORCH_ENV_FILE", str(tmp_path / "missing.env")) diff --git a/tests/test_fft_discovery.py b/tests/test_fft_discovery.py new file mode 100644 index 0000000..f516ff9 --- /dev/null +++ b/tests/test_fft_discovery.py @@ -0,0 +1,323 @@ +import copy +import json +import os +import shutil +import threading +from pathlib import Path + +import pytest +from conftest import ( + commit_agent_changes, + find_agent_workspace, + merge_agent_inputs, +) + +import hytorch +from example.fft_discovery.prepare import prepare +from example.fft_discovery.search import simplify_circuit +from example.fft_discovery.train import evaluate_submissions, known_structures, run +from example.fft_discovery.verifier import ( + CIRCUIT_FORMAT, + COST_MODEL, + INPUT_ORDER, + OUTPUT_ORDER, + TARGET_FORMAT, + TRANSFORM, + Target, + direct_dft_circuit, + split_radix_circuit, + structural_fingerprint, + verify_circuit, +) + + +def target(n=8, incumbent=10_000): + return Target.from_dict( + { + "format": TARGET_FORMAT, + "status": "calibration", + "n": n, + "transform": TRANSFORM, + "input_order": INPUT_ORDER, + "output_order": OUTPUT_ORDER, + "cost_model": COST_MODEL, + "incumbent": { + "name": "test incumbent", + "total_operations": incumbent, + "source": "generated test fixture", + }, + "limits": {"max_operations": 100_000}, + } + ) + + +def test_direct_dft_circuit_passes_exact_verification(): + for n in (4, 8): + result = verify_circuit(direct_dft_circuit(n), target(n)) + + assert result.valid + assert result.total_operations == result.additions + result.multiplications + assert result.live_operations > 0 + assert result.dead_operations == 0 + + +def test_independent_n4_formula_passes_exact_verification(): + operations = [] + + def linear(terms): + register = terms[0][1] + for sign, source in terms[1:]: + operations.append( + {"op": "add" if sign == 1 else "sub", "a": register, "b": source} + ) + register = 8 + len(operations) - 1 + return register + + outputs = [ + linear(terms) + for terms in ( + ((1, 0), (1, 2), (1, 4), (1, 6)), + ((1, 1), (1, 3), (1, 5), (1, 7)), + ((1, 0), (1, 3), (-1, 4), (-1, 7)), + ((1, 1), (-1, 2), (-1, 5), (1, 6)), + ((1, 0), (-1, 2), (1, 4), (-1, 6)), + ((1, 1), (-1, 3), (1, 5), (-1, 7)), + ((1, 0), (-1, 3), (-1, 4), (1, 7)), + ((1, 1), (1, 2), (-1, 5), (-1, 6)), + ) + ] + candidate = { + "format": CIRCUIT_FORMAT, + "n": 4, + "operations": operations, + "outputs": outputs, + } + + result = verify_circuit(candidate, target(4)) + + assert result.valid + assert result.total_operations == 24 + + +def test_split_radix_matches_published_operation_formula(): + for n in (4, 8, 16, 32): + result = verify_circuit(split_radix_circuit(n), target(n)) + + assert result.valid + assert result.total_operations == 4 * n * (n.bit_length() - 1) - 6 * n + 8 + assert result.dead_operations == 0 + + +def test_frozen_n32_target_matches_generated_incumbent(): + path = ( + Path(__file__).parents[1] / "example" / "fft_discovery" / "targets" / "n32.json" + ) + frozen = Target.load(path) + + result = verify_circuit(split_radix_circuit(32), frozen) + + assert frozen.status == "frozen" + assert result.valid + assert result.additions == 372 + assert result.multiplications == 84 + assert result.total_operations == frozen.incumbent_total == 456 + + +def test_verifier_rejects_an_incorrect_output(): + candidate = direct_dft_circuit(4) + candidate["outputs"][0] = 0 + + with pytest.raises(ValueError, match="output 0 coefficient"): + verify_circuit(candidate, target(4)) + + +def test_verifier_counts_only_live_operations(): + candidate = direct_dft_circuit(4) + baseline = verify_circuit(candidate, target(4)) + candidate["operations"].append({"op": "add", "a": 0, "b": 1}) + + result = verify_circuit(candidate, target(4)) + + assert result.total_operations == baseline.total_operations + assert result.dead_operations == 1 + + +def test_structural_fingerprint_ignores_description_and_json_layout(): + first = direct_dft_circuit(4) + second = copy.deepcopy(first) + second["description"] = "A renamed circuit is still the same structure." + second["untrusted_note"] = {"anything": True} + + assert structural_fingerprint(first) == structural_fingerprint(second) + second["outputs"] = list(reversed(second["outputs"])) + assert structural_fingerprint(first) != structural_fingerprint(second) + + +def test_exact_search_removes_semantically_duplicate_and_dead_operations(): + candidate = direct_dft_circuit(4) + input_count = 8 + duplicate = copy.deepcopy(candidate["operations"][0]) + candidate["operations"].insert(1, duplicate) + for operation in candidate["operations"][2:]: + for name in ("a", "b"): + if operation.get(name, -1) >= input_count + 1: + operation[name] += 1 + candidate["outputs"] = [ + register + 1 if register >= input_count + 1 else register + for register in candidate["outputs"] + ] + candidate["operations"].append({"op": "add", "a": 0, "b": 1}) + + simplified = simplify_circuit(candidate, target(4)) + result = verify_circuit(simplified, target(4)) + + assert result.valid + assert len(simplified["operations"]) < len(candidate["operations"]) + assert result.dead_operations == 0 + + +def test_submission_evaluation_rejects_prior_and_same_generation_duplicates(tmp_path): + root = tmp_path + (root / "incumbent").mkdir() + (root / "submissions/current").mkdir(parents=True) + circuit = direct_dft_circuit(4) + (root / "incumbent/circuit.json").write_text(json.dumps(circuit)) + renamed = copy.deepcopy(circuit) + renamed["description"] = "renamed" + for name in ("a.json", "b.json"): + (root / "submissions/current" / name).write_text(json.dumps(renamed)) + + evaluations = evaluate_submissions( + str(root), target(4), known_structures(str(root)) + ) + + assert len(evaluations) == 2 + assert all(value.verification.valid for value in evaluations) + assert all(value.duplicate_of == "incumbent/circuit.json" for value in evaluations) + + +def test_verifier_rejects_non_real_and_float_scale_constants(): + non_real = direct_dft_circuit(4) + non_real["operations"].append( + {"op": "scale", "a": 0, "constant": {"basis": {"1": "1"}}} + ) + floating = copy.deepcopy(non_real) + floating["operations"][-1]["constant"] = {"basis": {"0": 0.5}} + + with pytest.raises(ValueError, match="not real"): + verify_circuit(non_real, target(4)) + with pytest.raises(ValueError, match="integer or rational string"): + verify_circuit(floating, target(4)) + + +def test_prepare_creates_a_committed_calibration_state(tmp_path): + destination = tmp_path / "fft-state" + + prepare(destination, n=8) + + target_value = json.loads((destination / "control/target.json").read_text()) + prepared_target = Target.from_dict(target_value) + incumbent = json.loads((destination / "incumbent/circuit.json").read_text()) + result = verify_circuit(incumbent, prepared_target) + assert result.valid + assert result.total_operations == prepared_target.incumbent_total + assert (destination / ".git").is_dir() + assert (destination / "submissions/current/README.md").is_file() + assert (destination / "tools/fft_verify.py").is_file() + assert (destination / "tools/fft_search.py").is_file() + + +class FFTTestHarness(hytorch.harness.Harness): + def __init__(self, **kwargs): + super().__init__("fft-test") + self._lock = threading.Lock() + self._next_session = 0 + + def start(self, directory, prompt, mtype, **kwargs): + with self._lock: + session_id = f"session-{self._next_session}" + self._next_session += 1 + if prompt.startswith("Update your persistent native state"): + workspace = os.path.join(directory, "workspace") + selected = find_agent_workspace(workspace) + with open( + os.path.join(selected, "verified-feedback.md"), + "w", + encoding="utf-8", + ) as file: + file.write("Use the trusted verifier.\n") + session = hytorch.harness.Session(self.name, session_id, "") + return hytorch.harness.Result("updated owner", session) + statespace = os.path.join(directory, "statespace") + merge_agent_inputs(statespace) + source = os.path.join(statespace, "incumbent", "circuit.json") + destination = os.path.join( + statespace, "submissions", "current", f"{session_id}.json" + ) + if os.path.isfile(source): + shutil.copy2(source, destination) + commit_agent_changes(statespace, "test agent: submit incumbent") + session = hytorch.harness.Session(self.name, session_id, "") + return hytorch.harness.Result("submitted incumbent", session) + + def resume(self, session, directory, prompt, mtype, **kwargs): + workspace = os.path.join(directory, "workspace") + selected = find_agent_workspace(workspace) + with open( + os.path.join(selected, f"lesson-{session.id}.md"), + "w", + encoding="utf-8", + ) as file: + file.write("Use the trusted verifier.\n") + refs = os.path.join( + directory, "statespace", ".git", "refs", "hytorch", "inputs" + ) + input_count = len(os.listdir(refs)) + return hytorch.harness.Result( + json.dumps( + { + "update": "Use the trusted verifier.", + "feedback": ["Keep exact evidence."] * input_count, + } + ), + session, + ) + + def close(self, session): + pass + + def usage(self): + return hytorch.harness.Usage() + + +def test_training_checkpoints_and_resumes_offline(tmp_path, monkeypatch): + state = tmp_path / "fft-state" + run_dir = tmp_path / "fft-run" + prepare(state, n=4) + monkeypatch.setattr(hytorch.harness, "PiHarness", FFTTestHarness) + + options = { + "run_dir": run_dir, + "generations": 1, + "max_hours": 1, + "max_total_tokens": 10_000, + "max_stagnant": 5, + "backward_tokens": 100, + "temp": 0.1, + "model_type": "test-model", + "provider": "test-provider", + "allow_calibration": True, + "stop_on_record": True, + } + run(state_path=state, resume=False, **options) + run(state_path=None, resume=True, **options) + + latest = json.loads((run_dir / "latest.json").read_text()) + assert latest["generation"] == 2 + assert latest["total_submissions"] > 0 + assert latest["valid_submissions"] == latest["total_submissions"] + assert latest["novel_submissions"] == 0 + assert latest["duplicate_submissions"] == latest["total_submissions"] + assert (run_dir / "generation-0000/model/MODEL.json").is_file() + assert (run_dir / "generation-0001/state/reports/generation-0001.json").is_file() + assert (run_dir / "generation-0002/state/reports/generation-0002.json").is_file() diff --git a/tests/test_graph.py b/tests/test_graph.py index 036c585..6e2482c 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -12,6 +12,21 @@ def test_public_version_matches_release(): assert hytorch.__version__ == "0.1.0" +def test_all_builtin_native_harnesses_are_executable_adapters(): + builtins = { + "pi": hytorch.harness.PiHarness, + "codex": hytorch.harness.CodexHarness, + "claude-code": hytorch.harness.ClaudeCodeHarness, + "opencode": hytorch.harness.OpenCodeHarness, + "hermes": hytorch.harness.HermesHarness, + "prime-agent": hytorch.harness.PrimeAgentHarness, + } + + registered = hytorch.harness.registered() + for name, adapter in builtins.items(): + assert isinstance(registered[name], adapter) + + class NullHarness(hytorch.harness.Harness): def start(self, directory, prompt, mtype, **kwargs): merge_agent_inputs(os.path.join(directory, "statespace")) @@ -19,7 +34,7 @@ def start(self, directory, prompt, mtype, **kwargs): return hytorch.harness.Result("done", session) def resume(self, session, directory, prompt, mtype, **kwargs): - return "done" + return hytorch.harness.Result("done", session) def close(self, session): pass @@ -94,7 +109,7 @@ def start(self, directory, prompt, mtype, **kwargs): return hytorch.harness.Result("done", session) def resume(self, session, directory, prompt, mtype, **kwargs): - return "done" + return hytorch.harness.Result("done", session) def close(self, session): pass @@ -130,6 +145,27 @@ def forward(self, value): assert len(output.feed_fn.parents) == 2 +def test_training_forks_one_episode_per_repeated_parameter_execution(new_repo): + class Recurrent(mn.Module): + def __init__(self): + super().__init__() + self.layer = mn.Linear(1, 1) + + def forward(self, value): + return self.layer(self.layer(value)[0])[0] + + harness = hytorch.harness.register(NullHarness("recurrent-state")) + model = Recurrent().to(harness) + + output = model(hytorch.space(new_repo.root, harness=harness)) + + second = output.feed_fn + first = second.parents[0] + assert second.parameters[0].relative_path == first.parameters[0].relative_path + assert second.workspace != first.workspace + assert second.session.id != first.session.id + + def test_module_to_supplies_harness_and_mtype(new_repo): class RecordingHarness(hytorch.harness.Harness): def __init__(self): @@ -143,7 +179,7 @@ def start(self, directory, prompt, mtype, **kwargs): return hytorch.harness.Result("done", session) def resume(self, session, directory, prompt, mtype, **kwargs): - return "done" + return hytorch.harness.Result("done", session) def close(self, session): pass diff --git a/tests/test_linear.py b/tests/test_linear.py index ea83ba9..19244ec 100644 --- a/tests/test_linear.py +++ b/tests/test_linear.py @@ -35,7 +35,7 @@ def start(self, directory, prompt, mtype, **kwargs): return hytorch.harness.Result("done", session) def resume(self, session, directory, prompt, mtype, **kwargs): - return "done" + return hytorch.harness.Result("done", session) def close(self, session): pass @@ -120,8 +120,9 @@ def test_dense_linear_runs_one_agent_per_output_without_state_injection(new_repo assert all(expected not in prompt for prompt in harness.prompts) assert all(os.path.isdir(output.feed_fn.workspace) for output in outputs) assert all(os.path.basename(output.dir) == "statespace" for output in outputs) - assert harness.layouts == [["statespace", "workspace"]] * 3 + assert harness.layouts == [["parameter", "statespace", "workspace"]] * 3 for output in outputs: + assert os.path.isdir(output.feed_fn.parameter) assert os.path.isdir(os.path.join(output.dir, ".git")) assert not os.path.exists(os.path.join(output.feed_fn.root, "inputs")) assert ( @@ -144,23 +145,26 @@ def test_dense_linear_runs_one_agent_per_output_without_state_injection(new_repo assert "Decide carefully." in model.layer.weight[index].text() -def test_forward_rejects_workspace_mutation(new_repo): - class InvalidHarness(RecordingHarness): +def test_forward_keeps_workspace_mutation_private(new_repo): + class MemoryHarness(RecordingHarness): def start(self, directory, prompt, mtype, **kwargs): + result = super().start(directory, prompt, mtype, **kwargs) path = os.path.join( find_agent_workspace(os.path.join(directory, "workspace")), - "AGENTS.md", + "forward-memory.md", ) - os.chmod(path, 0o644) with open(path, "w") as file: - file.write("illegal\n") - session = hytorch.harness.Session(self.name, "invalid", "") - return hytorch.harness.Result("done", session) + file.write("candidate only\n") + return result - harness = hytorch.harness.register(InvalidHarness("invalid-forward")) + harness = hytorch.harness.register(MemoryHarness("forward-memory")) model = Model(1, 1).to(harness) - with pytest.raises(RuntimeError, match="forward modified read-only workspace"): - model(hytorch.space(new_repo.root, harness=harness)) + output = model(hytorch.space(new_repo.root, harness=harness))[0] + + assert os.path.isfile(os.path.join(output.feed_fn.workspace, "forward-memory.md")) + assert not os.path.exists( + os.path.join(model.layer.weight[0].path, "forward-memory.md") + ) def test_forward_requires_committed_agent_changes(new_repo): diff --git a/tests/test_native_view.py b/tests/test_native_view.py new file mode 100644 index 0000000..095da99 --- /dev/null +++ b/tests/test_native_view.py @@ -0,0 +1,44 @@ +import os +import shutil + +import pytest + +from hytorch._native_view import native_node_view + + +def _node(root): + (root / "statespace").mkdir(parents=True) + (root / "parameter").mkdir() + (root / "workspace").mkdir() + return root + + +def test_native_view_keeps_one_working_path_across_materializations(tmp_path): + first = _node(tmp_path / "first") + with native_node_view(str(first)) as first_view: + assert os.path.realpath(os.path.join(first_view, "statespace")) == str( + (first / "statespace").resolve() + ) + assert os.path.realpath(os.path.join(first_view, "parameter")) == str( + (first / "parameter").resolve() + ) + + second = tmp_path / "second" + (second / "statespace").mkdir(parents=True) + (second / "parameter").mkdir() + shutil.copytree(first / "workspace", second / "workspace") + with native_node_view(str(second)) as second_view: + assert second_view == first_view + assert os.path.realpath(os.path.join(second_view, "workspace")) == str( + (second / "workspace").resolve() + ) + + +def test_native_view_rejects_agent_changes_to_its_identity(tmp_path): + root = _node(tmp_path / "node") + + with pytest.raises(RuntimeError, match="changed its native node identity"): + with native_node_view(str(root)): + (root / "workspace" / ".hytorch" / "node-id").write_text( + "00000000000000000000000000000000\n" + ) diff --git a/tests/test_opencode_hermes_harness.py b/tests/test_opencode_hermes_harness.py new file mode 100644 index 0000000..da68834 --- /dev/null +++ b/tests/test_opencode_hermes_harness.py @@ -0,0 +1,317 @@ +import json +import os +import sqlite3 +import subprocess + +import pytest + +from hytorch.harness import Result, Session +from hytorch.hermes_harness import HermesHarness +from hytorch.opencode_harness import OpenCodeHarness + + +def _node(tmp_path): + root = tmp_path / "node" + (root / "statespace").mkdir(parents=True) + (root / "workspace").mkdir() + return root + + +def _opencode_output(session_id="ses_one", text="done"): + return "\n".join( + ( + json.dumps({"type": "step_start", "sessionID": session_id}), + json.dumps( + { + "type": "text", + "sessionID": session_id, + "part": {"text": text}, + } + ), + ) + ) + + +def test_opencode_start_creates_then_continues_native_profile(tmp_path): + root = _node(tmp_path) + calls = [] + + def run(command, **kwargs): + assert os.path.realpath(os.path.join(kwargs["cwd"], "statespace")) == str( + (root / "statespace").resolve() + ) + calls.append((command, kwargs)) + return subprocess.CompletedProcess(command, 0, _opencode_output(), "") + + harness = OpenCodeHarness( + model="openai/gpt-5", + environment={"OPENCODE_AUTH_CONTENT": '{"openai":{"type":"api","key":"x"}}'}, + runner=run, + ) + result = harness.start(str(root), "solve it", None, max_tokens=321) + continued = harness.start(str(root), "next epoch", None) + + command, kwargs = calls[0] + stable = command[5] + assert command == [ + "opencode", + "run", + "--format", + "json", + "--dir", + stable, + "--auto", + "--model", + "openai/gpt-5", + ] + assert kwargs["input"] == "solve it" + assert kwargs["cwd"] == stable + assert kwargs["env"]["HOME"] == os.path.join(stable, "workspace", "home") + assert kwargs["env"]["XDG_DATA_HOME"] == os.path.join(stable, "workspace", "data") + assert kwargs["env"]["OPENCODE_AUTH_CONTENT"].startswith("{") + assert kwargs["env"]["OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"] == "321" + assert result.text == "done" + assert calls[1][0][calls[1][0].index("--session") + 1] == "ses_one" + assert continued.session.id == "ses_one" + assert result.session == Session("opencode", "ses_one", str(root / "workspace")) + + +def test_opencode_resume_uses_exact_session_and_returns_current_handle(tmp_path): + root = _node(tmp_path) + calls = [] + + def run(command, **kwargs): + calls.append(command) + return subprocess.CompletedProcess( + command, 0, _opencode_output("ses_one", "learned"), "" + ) + + harness = OpenCodeHarness(runner=run) + result = harness.resume( + Session("opencode", "ses_one", str(root / "workspace")), + str(root), + "update yourself", + "anthropic/claude-sonnet-4", + ) + + assert calls[0][-4:] == [ + "--session", + "ses_one", + "--model", + "anthropic/claude-sonnet-4", + ] + assert result.text == "learned" + assert result.session.id == "ses_one" + + +def test_opencode_close_preserves_profile(tmp_path): + profile = tmp_path / "profile" + profile.mkdir() + marker = profile / "memory" + marker.write_text("keep") + + OpenCodeHarness().close(Session("opencode", "one", str(profile))) + + assert marker.read_text() == "keep" + + +def test_opencode_rejects_invalid_event_output(tmp_path): + root = _node(tmp_path) + harness = OpenCodeHarness( + runner=lambda command, **kwargs: subprocess.CompletedProcess( + command, 0, "not-json", "" + ) + ) + + with pytest.raises(RuntimeError, match="invalid JSON event"): + harness.start(str(root), "task", None) + + +def test_opencode_rejects_credentials_in_persistent_profile(tmp_path): + root = _node(tmp_path) + auth = root / "workspace" / "data" / "opencode" / "auth.json" + auth.parent.mkdir(parents=True) + auth.write_text("{}") + harness = OpenCodeHarness( + runner=lambda command, **kwargs: subprocess.CompletedProcess( + command, 0, _opencode_output(), "" + ) + ) + + with pytest.raises(RuntimeError, match="credentials must not be stored"): + harness.start(str(root), "task", None) + + assert auth.exists() + + +def test_opencode_removes_runtime_auth_artifact(tmp_path): + root = _node(tmp_path) + + def run(command, **kwargs): + auth = root / "workspace" / "data" / "opencode" / "auth.json" + auth.parent.mkdir(parents=True, exist_ok=True) + auth.write_text('{"secret":"runtime"}') + return subprocess.CompletedProcess(command, 0, _opencode_output(), "") + + OpenCodeHarness(runner=run).start(str(root), "task", None) + + assert not (root / "workspace" / "data" / "opencode" / "auth.json").exists() + + +def test_hermes_starts_first_profile_session_and_parses_id(tmp_path): + root = _node(tmp_path) + calls = [] + + def run(command, **kwargs): + assert os.path.realpath(os.path.join(kwargs["cwd"], "statespace")) == str( + (root / "statespace").resolve() + ) + calls.append((command, kwargs)) + return subprocess.CompletedProcess( + command, 0, "finished\n", "session_id: h_1\n" + ) + + harness = HermesHarness( + provider="openrouter", + model="anthropic/claude-sonnet-4", + environment={"OPENROUTER_API_KEY": "secret"}, + runner=run, + ) + result = harness.start(str(root), "solve it", None, max_tokens=654) + + command, kwargs = calls[0] + assert command == [ + "hermes", + "chat", + "-Q", + "-q", + "solve it", + "--yolo", + "--no-restore-cwd", + "--provider", + "openrouter", + "--model", + "anthropic/claude-sonnet-4", + ] + stable = kwargs["cwd"] + assert kwargs["env"]["HERMES_HOME"] == os.path.join(stable, "workspace") + assert kwargs["env"]["HOME"] == os.path.join(stable, "workspace", "home") + assert kwargs["env"]["TERMINAL_CWD"] == stable + assert kwargs["env"]["HERMES_MAX_TOKENS"] == "654" + assert result == Result( + "finished", Session("hermes", "h_1", str(root / "workspace")) + ) + + +def test_hermes_start_continues_existing_native_session(tmp_path): + root = _node(tmp_path) + database = root / "workspace" / "state.db" + connection = sqlite3.connect(database) + connection.execute("CREATE TABLE sessions (source TEXT)") + connection.execute("INSERT INTO sessions VALUES ('cli')") + connection.commit() + connection.close() + calls = [] + + def run(command, **kwargs): + calls.append(command) + return subprocess.CompletedProcess(command, 0, "continued", "session_id: h_2") + + HermesHarness(runner=run).start(str(root), "next epoch", None) + + assert "--continue" in calls[0] + + +def test_hermes_start_resumes_saved_compaction_tip_across_epochs(tmp_path): + root = _node(tmp_path) + calls = [] + + def run(command, **kwargs): + calls.append(command) + return subprocess.CompletedProcess( + command, 0, "continued", "session_id: compacted-tip" + ) + + harness = HermesHarness(runner=run) + harness.start(str(root), "epoch one", None) + harness.start(str(root), "epoch two", None) + + assert "--resume" not in calls[0] + assert calls[1][calls[1].index("--resume") + 1] == "compacted-tip" + + +def test_hermes_resume_tracks_compaction_tip(tmp_path): + root = _node(tmp_path) + calls = [] + + def run(command, **kwargs): + calls.append(command) + return subprocess.CompletedProcess( + command, + 0, + "updated\n", + "warning\nsession_id: old\nsession_id: compacted_tip\n", + ) + + result = HermesHarness(runner=run).resume( + Session("hermes", "old", str(root / "workspace")), + str(root), + "learn", + None, + ) + + assert calls[0][-2:] == ["--resume", "old"] + assert result.session.id == "compacted_tip" + + +def test_hermes_credential_sidecar_never_remains_in_profile(tmp_path): + root = _node(tmp_path) + sidecar = tmp_path / "auth.json" + sidecar.write_text('{"token":"old"}') + observed = [] + + def run(command, **kwargs): + staged = root / "workspace" / "auth.json" + observed.append((staged.exists(), oct(staged.stat().st_mode & 0o777))) + staged.write_text('{"token":"refreshed"}') + return subprocess.CompletedProcess(command, 0, "done", "session_id: h_2\n") + + harness = HermesHarness(credential_files={"auth.json": str(sidecar)}, runner=run) + harness.start(str(root), "task", None) + + assert observed == [(True, "0o600")] + assert sidecar.read_text() == '{"token":"refreshed"}' + assert not (root / "workspace" / "auth.json").exists() + + +def test_hermes_rejects_credentials_in_persistent_profile(tmp_path): + root = _node(tmp_path) + (root / "workspace" / ".env").write_text("TOKEN=secret") + harness = HermesHarness( + runner=lambda command, **kwargs: subprocess.CompletedProcess( + command, 0, "done", "session_id: h_3\n" + ) + ) + + with pytest.raises(RuntimeError, match="credentials must not be stored"): + harness.start(str(root), "task", None) + + assert (root / "workspace" / ".env").exists() + + +def test_hermes_close_preserves_native_profile(tmp_path): + profile = tmp_path / "profile" + profile.mkdir() + database = profile / "state.db" + database.write_bytes(b"native state") + + HermesHarness().close(Session("hermes", "one", str(profile))) + + assert database.read_bytes() == b"native state" + + +def test_harnesses_reject_foreign_sessions(): + with pytest.raises(ValueError, match="cannot use"): + OpenCodeHarness().close(Session("hermes", "one", "/profile")) + with pytest.raises(ValueError, match="cannot use"): + HermesHarness().close(Session("opencode", "one", "/profile")) diff --git a/tests/test_pi.py b/tests/test_pi.py index 471631c..927ddc5 100644 --- a/tests/test_pi.py +++ b/tests/test_pi.py @@ -12,11 +12,12 @@ import os import subprocess +from pathlib import Path import pytest import hytorch -from hytorch.pi_harness import PiRuntime +from hytorch.pi_harness import PiRuntime, _staged_agent_directory def test_pi_harness_defaults_to_terra(): @@ -170,20 +171,22 @@ def fake_run(args, **kwargs): read_only=(str(statespace),), environment_path=str(environment), provider="openai", + agent_dir=str(tmp_path / "agent"), ) run = next( args for args in calls if args[:4] == ["docker", "run", "--rm", "--init"] ) assert result.returncode == 0 - assert len(uploads) == 4 + assert len(uploads) == 5 assert not any(destination == str(statespace) for _, destination in downloads) assert any(destination == str(workspace) for _, destination in downloads) assert any(destination == str(session) for _, destination in downloads) + assert any(destination == str(tmp_path / "agent") for _, destination in downloads) assert any("dst=/workspace/statespace,readonly" in arg for arg in run) assert any("dst=/workspace/workspace" in arg for arg in run) assert run[run.index("--env-file") + 1] == str(environment) - assert len([args for args in calls if args[1:3] == ["volume", "rm"]]) == 4 + assert len([args for args in calls if args[1:3] == ["volume", "rm"]]) == 5 def test_docker_context_detection_recognizes_remote_endpoint(monkeypatch): @@ -201,6 +204,83 @@ def test_docker_context_detection_recognizes_remote_endpoint(monkeypatch): assert harness._uses_remote_docker() +def test_pi_start_continues_node_session_across_epochs(tmp_path, monkeypatch): + root = tmp_path / "node" + (root / "workspace").mkdir(parents=True) + calls = [] + harness = PiRuntime(docker=False) + + def fake_invoke(directory, prompt, mtype, **kwargs): + calls.append(kwargs["session_file"]) + session_file = kwargs["session_file"] + if session_file is None: + session_file = os.path.join(kwargs["session_dir"], "native.jsonl") + with open(session_file, "w", encoding="utf-8") as file: + file.write('{"type":"session","id":"native"}\n') + return prompt, "native", session_file, hytorch.harness.Usage() + + monkeypatch.setattr(harness, "_invoke", fake_invoke) + + first = harness.start(str(root), "epoch one", None) + second = harness.start(str(root), "epoch two", None) + + assert calls == [None, first.session.storage] + assert second.session.id == first.session.id + assert second.session.storage.startswith(str(root / "workspace")) + + +def test_pi_resume_returns_result_and_close_preserves_session(tmp_path, monkeypatch): + root = tmp_path / "node" + session_dir = root / "workspace" / ".pi" / "sessions" + session_dir.mkdir(parents=True) + session_file = session_dir / "native.jsonl" + session_file.write_text("session\n", encoding="utf-8") + harness = PiRuntime(docker=False) + session = hytorch.harness.Session("pi", "native", str(session_file)) + monkeypatch.setattr( + harness, + "_invoke", + lambda *args, **kwargs: ( + "updated", + "native", + str(session_file), + hytorch.harness.Usage(), + ), + ) + + result = harness.resume(session, str(root), "feedback", None) + harness.close(result.session) + + assert isinstance(result, hytorch.harness.Result) + assert result.text == "updated" + assert session_file.is_file() + + +def test_pi_profile_keeps_native_files_but_never_auth(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + operator = tmp_path / "home" / ".pi" / "agent" + operator.mkdir(parents=True) + (operator / "auth.json").write_text( + '{"openai-codex":{"expires":1,"access":"secret"}}', encoding="utf-8" + ) + persistent = tmp_path / "candidate" / "agent" + persistent.mkdir(parents=True) + (persistent / "auth.json").write_text("leaked", encoding="utf-8") + (persistent / "settings.json").write_text("{}", encoding="utf-8") + + with _staged_agent_directory(str(persistent)) as staged: + assert Path(staged, "auth.json").is_file() + Path(staged, "memory.md").write_text("learned", encoding="utf-8") + Path(staged, "auth.json").write_text( + '{"openai-codex":{"expires":2,"access":"refreshed"}}', + encoding="utf-8", + ) + + assert (persistent / "memory.md").read_text(encoding="utf-8") == "learned" + assert not (persistent / "auth.json").exists() + assert "refreshed" in (operator / "auth.json").read_text(encoding="utf-8") + + @pytest.mark.skipif( os.environ.get("HYTORCH_PI_TEST") != "1", reason="set HYTORCH_PI_TEST=1 to run this test (spawns a real Pi/OpenAI session)", diff --git a/tests/test_prime_harness.py b/tests/test_prime_harness.py new file mode 100644 index 0000000..3bcf3f5 --- /dev/null +++ b/tests/test_prime_harness.py @@ -0,0 +1,182 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +import hytorch +import hytorch.prime_harness as prime_module +from hytorch.prime_harness import PrimeRuntime, _staged_prime_profile + + +def _json_run(session_id: str, text: str) -> str: + return "\n".join( + [ + json.dumps({"type": "session", "id": session_id}), + json.dumps( + { + "type": "message_end", + "message": { + "role": "assistant", + "content": [{"type": "text", "text": text}], + "usage": { + "input": 11, + "output": 7, + "cacheRead": 3, + "cacheWrite": 2, + }, + }, + } + ), + ] + ) + + +def test_prime_start_persists_and_continues_complete_native_state( + tmp_path, monkeypatch +): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setattr(prime_module, "command_environment", lambda: {}) + root = tmp_path / "node" + (root / "workspace").mkdir(parents=True) + (root / "statespace").mkdir() + calls = [] + + def fake_run(args, **kwargs): + calls.append((args, kwargs["env"])) + sessions = root / "workspace" / ".prime" / "sessions" + session_file = sessions / "prime-session.jsonl" + session_file.write_text('{"type":"session"}\n', encoding="utf-8") + artifacts = root / "workspace" / ".prime" / "session-artifacts" + artifacts.mkdir(parents=True, exist_ok=True) + (artifacts / "kernel-state.dill").write_text("kernel", encoding="utf-8") + profile = Path(kwargs["env"]["PRIME_AGENT_CODING_AGENT_DIR"]) + (profile / "memory.md").write_text("native memory", encoding="utf-8") + return subprocess.CompletedProcess( + args, 0, _json_run("prime-session", "finished"), "" + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + harness = PrimeRuntime(binary="prime-agent-test") + + first = harness.start(str(root), "forward one", None) + second = harness.start(str(root), "forward two", None) + + first_args, first_env = calls[0] + second_args, _ = calls[1] + assert first_args[:3] == ["prime-agent-test", "--mode", "json"] + assert "--resume" not in first_args + resumed = second_args[second_args.index("--resume") + 1] + assert Path(resumed).name == Path(first.session.storage).name + assert ( + first_args[first_args.index("--cwd") + 1] + == second_args[second_args.index("--cwd") + 1] + ) + assert first_env["PRIME_AGENT_SESSION_DIR"].endswith("/workspace/.prime/sessions") + assert second.session.id == first.session.id + assert (root / "workspace" / ".prime" / "agent" / "memory.md").read_text( + encoding="utf-8" + ) == "native memory" + assert ( + root / "workspace" / ".prime" / "session-artifacts" / "kernel-state.dill" + ).is_file() + assert harness.usage() == hytorch.harness.Usage(11 * 2, 7 * 2, 3 * 2, 2 * 2) + + +def test_prime_resume_returns_result_and_close_keeps_native_state( + tmp_path, monkeypatch +): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setattr(prime_module, "command_environment", lambda: {}) + root = tmp_path / "node" + (root / "statespace").mkdir(parents=True) + session_dir = root / "workspace" / ".prime" / "sessions" + session_dir.mkdir(parents=True) + session_file = session_dir / "same.jsonl" + session_file.write_text("session\n", encoding="utf-8") + + monkeypatch.setattr( + subprocess, + "run", + lambda args, **kwargs: subprocess.CompletedProcess( + args, 0, _json_run("same", '{"feedback": []}'), "" + ), + ) + harness = PrimeRuntime() + session = hytorch.harness.Session("prime-agent", "same", str(session_file)) + + result = harness.resume(session, str(root), "backward", None) + harness.close(result.session) + + assert isinstance(result, hytorch.harness.Result) + assert result.text == '{"feedback": []}' + assert session_file.is_file() + + +def test_prime_profile_excludes_auth_and_process_state(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + operator = tmp_path / "home" / ".prime" / "agent" + operator.mkdir(parents=True) + (operator / "auth.json").write_text( + '{"openai-codex":{"expires":1,"access":"secret"}}', encoding="utf-8" + ) + persistent = tmp_path / "candidate" / "agent" + (persistent / "daemon-workers").mkdir(parents=True) + (persistent / "daemon-workers" / "worker.json").write_text( + "secret token", encoding="utf-8" + ) + (persistent / "auth.json").write_text("leaked", encoding="utf-8") + + with _staged_prime_profile(str(persistent)) as staged: + assert Path(staged, "auth.json").is_file() + Path(staged, "harness").mkdir() + Path(staged, "harness", "harness_state.json").write_text( + '{"memory":"lesson"}', encoding="utf-8" + ) + Path(staged, "auth.json").write_text( + '{"openai-codex":{"expires":2,"access":"refreshed"}}', + encoding="utf-8", + ) + + assert not (persistent / "auth.json").exists() + assert not (persistent / "daemon-workers").exists() + assert (persistent / "harness" / "harness_state.json").is_file() + assert "refreshed" in (operator / "auth.json").read_text(encoding="utf-8") + + +def test_prime_rejects_wrong_session_and_invalid_json(tmp_path, monkeypatch): + root = tmp_path / "node" + (root / "statespace").mkdir(parents=True) + session_dir = root / "workspace" / ".prime" / "sessions" + session_dir.mkdir(parents=True) + session_file = session_dir / "same.jsonl" + session_file.write_text("session\n", encoding="utf-8") + harness = PrimeRuntime() + + with pytest.raises(ValueError, match="cannot resume"): + harness.resume( + hytorch.harness.Session("pi", "same", str(session_file)), + str(root), + "prompt", + None, + ) + + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setattr(prime_module, "command_environment", lambda: {}) + monkeypatch.setattr( + subprocess, + "run", + lambda args, **kwargs: subprocess.CompletedProcess(args, 0, "not-json", ""), + ) + with pytest.raises(RuntimeError, match="invalid JSON event"): + harness.start(str(root), "prompt", None) + + +def test_prime_provider_selection_uses_api_key_only_when_implicit(): + assert PrimeRuntime()._provider_for({"OPENAI_API_KEY": "secret"}) == "openai" + assert ( + PrimeRuntime(provider="openai-codex")._provider_for( + {"OPENAI_API_KEY": "secret"} + ) + == "openai-codex" + )