diff --git a/.env.example b/.env.example index e22ca636..13bb9c43 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,17 @@ # Example of a credential a live/network-gated test would need. Tests that # require one must skip cleanly when it is unset. # EXAMPLE_API_KEY=replace-me + +# OpenRouter key for every live model call (hive_view --live, the OpenHuman +# examples, examples/openhuman --bin euler_hive). All inference goes here. +# OPENROUTER_API_KEY=replace-me +# Optional model override for those live runs. +# OPENROUTER_MODEL=deepseek/deepseek-v4-pro + +# TinyHumans API key for euler_hive's non-inference modules: web search +# through the backend's agent integrations (Parallel, Gemini grounding) via +# the TinyHumans SDK, and CortexDB memory behind the TinyHumans backend. +# Never used for inference. +TINYHUMANS_API_KEY=replace-me +# Optional TinyHumans backend origin (default https://api.tinyhumans.ai). +# TINYHUMANS_BACKEND_URL=https://api.tinyhumans.ai diff --git a/.gitignore b/.gitignore index 828714cd..20e59b41 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,12 @@ /target/ /examples/*/target/ /examples/openhuman/workspace/ + +# Live hive_view run artifacts (requests, replies, frames, usage). Kept out +# of target/ so `cargo clean` does not wipe them; override with HIVE_RUNS_DIR. +# The euler_hive example writes its runs (problem statements, transcripts, +# sandbox programs, answers, oracles) here too; none of it may be committed. +/hive-runs/ **/*.rs.bk *.pdb diff --git a/AGENTS.md b/AGENTS.md index 813f4a14..1fdf8e61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,13 +19,14 @@ sees of the shared transcript. Three rules decide what belongs here: 1. **The host owns storage.** The core mechanics open no database, file, or - socket. `crates/tinyhivemind-core` is a pure algebra; `crates/tinyhivemind` owns - *ports* a host implements. In particular there is no second + socket. `crates/tinyhivemind-core` is a pure algebra, and its `runtime` + module owns the *ports* a host implements. In particular there is no second append-only journal: messages are addressed by sequence number across surfaces the host owns, so a second log could not be made consistent with the first. -2. **Keep host types at the adapter boundary.** The core, runtime, hive, - embed, and TypeSafe crates do not name types from a consuming application. +2. **Keep host types at the adapter boundary.** The core crate (including its + `runtime`, `hive`, `embed`, `typesafe`, and `driver` modules) and + `tinyhivemind-lang` do not name types from a consuming application. They accept snapshots, borrowed views, or narrow ports. The `tinyhivemind-openhuman` adapter explicitly names OpenHuman types and keeps that dependency out of the pure crates. diff --git a/README.md b/README.md index a94c09de..ee2bb7ed 100644 --- a/README.md +++ b/README.md @@ -155,8 +155,7 @@ option — never straight at the option: Every member computes an urge from the salience field and its own affinity, and whoever bids highest takes the floor. A member whose urge never clears its threshold does not bid at all. This is the response-threshold model of division -of labour, and it is also -[Pandemonium](https://en.wikipedia.org/wiki/Pandemonium_architecture)'s +of labour, and it is also [Pandemonium](https://en.wikipedia.org/wiki/Pandemonium_architecture)'s decision demon, which is the same idea arrived at from the AI side. ```text @@ -168,8 +167,7 @@ auditor -- <- threshold never cleared, does not bid floor = planner ``` -Every one of those is -[fixed-point](https://en.wikipedia.org/wiki/Fixed-point_arithmetic) integer +Every one of those is [fixed-point](https://en.wikipedia.org/wiki/Fixed-point_arithmetic) integer arithmetic. Every payload derives `Eq`, and every episode replays byte for byte from the same transcript. @@ -364,7 +362,8 @@ commits the round's state after all authorized turns are appended. `tinyhivemind-lang` makes a hive’s definition editable data: roles, goals, seats, memory bindings and policies, with guarded patches and an evaluated -lineage. Read the [language specification](docs/specs/hive-language.md). +lineage. A package is a `hive.json`, Markdown prompts, and a constitution no self-edit may +touch. Read the [language specification](docs/specs/hive-language.md), or start from the [gallery](crates/tinyhivemind-lang/examples/hives). `tinyhivemind-tools` renders native `tinytools::ToolSpec` definitions and records episode calls. `tinyhivemind-hives` coordinates durable messages and @@ -452,11 +451,10 @@ the library asks the host to reinitialize instead of silently skipping history. No. The host owns the agent lifecycle, model calls, queueing, storage, and authorization. The core mention fold resolves a direct addressee; the host can use that decision to schedule one turn. The optional embedding layer can -recommend a primary and bounded specialist set; the `hive` module can -select a round inside a -bounded deliberation. The host decides whether to run those turns, what models -to use, what long-term memory or search to provide, and how to persist the -result. A workspace such as Buzz could host these mechanics, but it is a +recommend a primary and bounded specialist set; the `hive` module can select a +round inside a bounded deliberation. The host decides whether to run those +turns, what models to use, what long-term memory or search to provide, and how +to persist the result. A workspace such as Buzz could host these mechanics, but it is a separate system with its own routing and context policy. ## Use it @@ -467,7 +465,7 @@ git submodule add https://github.com/tinyhumansai/tinyhivemind.git vendor/tinyhi ```toml [dependencies] -tinyhivemind = { path = "vendor/tinyhivemind/crates/tinyhivemind" } +tinyhivemind-core = { path = "vendor/tinyhivemind/crates/tinyhivemind-core" } ``` ```sh @@ -486,6 +484,7 @@ That prints one deliberation episode turn by turn—the fastest way to see the m | [Cross-desk referral](https://github.com/tinyhumansai/tinyhivemind/wiki/Cross-desk-referral) | asking another channel a question, and getting the answer back | | [Hive episodes](https://github.com/tinyhumansai/tinyhivemind/wiki/Hive-episodes) | salience, quorum, cross-inhibition, and the attention market | | [Trace grammar](https://github.com/tinyhumansai/tinyhivemind/wiki/Trace-grammar) | what a marker deposits, and what real models get wrong | +| [Hive language](https://github.com/tinyhumansai/tinyhivemind/wiki/Hive-language) | a hive as a portable package, its guarded edits, and the gallery | | [Episode policy](https://github.com/tinyhumansai/tinyhivemind/wiki/Episode-policy) | every setting, and how to tune it to the size of a desk | | [Benchmarks](https://github.com/tinyhumansai/tinyhivemind/wiki/Benchmarks) | the full report, including what it does not show | | [Host integration](https://github.com/tinyhumansai/tinyhivemind/wiki/Host-integration) | the ports, and what your application owes the library | diff --git a/ROADMAP.md b/ROADMAP.md index 252b2186..36fe5301 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,11 +15,11 @@ consumer builds. | P1 | Chat identity: `MAIN_THREAD_ID`, `GENERAL_DESK`, `is_general_chat`, `same_conversation` | **done** | | P2 | Desk types, then the membership algebra behind `DeskSet<'a>` | **done** | | P3 | The `@` grammar, `Mention`/`MentionTarget`, and resolution over `Roster`/`Person` | **done** | -| P4 | `crates/tinyhivemind`: the `SessionLog` port, the paging walk, and the **attributed** transcript projection | **done** | +| P4 | The `SessionLog` port (originally `crates/tinyhivemind`, now core's `runtime` module), the paging walk, and the **attributed** transcript projection | **done** | | P5 | Continuous sharing — re-seed on a watermark rather than only on a rebind | **done** | | P6 | The responder ladder and a model-backed `Selector` port | **core plan retained**; unused waiting port retired | | P7 | A bounded mention-dispatch edge | **core decision retained**; unused queue wrapper retired | -| P8 | `crates/tinyhivemind-hive`: bounded group deliberation — the trace grammar, salience, quorum with cross-inhibition, the attention market, and the episode state machine | **done** | +| P8 | Bounded group deliberation (originally `crates/tinyhivemind-hive`, now core's `hive` module) — the trace grammar, salience, quorum with cross-inhibition, the attention market, and the episode state machine | **done** | | P9 | `!refute`, evidential grounding, grounded objections, and the benchmark arm that scored them | Historical experiment; refutation trace retained, losing quorum knobs retired | | P10 | A transactive-memory directory folded from traces, `BidReason::Knows`, and `!defer` | Directory retained for division; losing delegation and deferral paths retired | | P11 | `SessionMessage.parent` and the structured trace sidecar | planned | @@ -106,9 +106,9 @@ advocate rather than debiting an option, and an attention market that ranks eligible speakers. A later step may authorize a bounded round of them. It adds **no port**. An episode is a pure fold, and the host does its waiting -through the session log and turn scheduler it already implements. -`crates/tinyhivemind-hive` is in the `pure_crates` list in -`.github/scripts/assert-pure.sh`. +through the session log and turn scheduler it already implements. It now +lives in `crates/tinyhivemind-core/src/hive/`, and `tinyhivemind-core` is in +the `pure_crates` list in `.github/scripts/assert-pure.sh`. It is also not a claim that group deliberation produces better answers. Almost every positive multi-agent result in the literature is confounded by compute, @@ -158,8 +158,8 @@ epoch-scoped consent, under which a grant issued later cannot cover a request minted earlier. `Ask` names exactly one person, resolved through the existing roster and desk algebra, and never an agent. -The original phase also added an `ApprovalGate` waiting port in -`crates/tinyhivemind`. That unused wrapper has since been retired; a host +The original phase also added an `ApprovalGate` waiting port in the former +`crates/tinyhivemind` runtime crate. That unused wrapper has since been retired; a host handles the wait after the pure decision. The ADR's lasting choice is that the fold is total: a gate that can fail can be bypassed by failing, so `approve` returns no `Result` and adds no `Error` variant. See diff --git a/crates/tinyhivemind-core/examples/bench/README.md b/crates/tinyhivemind-core/examples/bench/README.md index b18da36c..fda370ae 100644 --- a/crates/tinyhivemind-core/examples/bench/README.md +++ b/crates/tinyhivemind-core/examples/bench/README.md @@ -109,7 +109,7 @@ Every arm decides the same rooms from the same private evaluations. | arm | what it is | | --- | --- | -| `ladder` | Today's behaviour: `responder_plan` in `tinyhivemind` selects one responder off the real ladder and that agent answers alone. One deliberation turn — but two model calls when the ladder takes the `Select` rung, since the router has to be asked who answers before anybody does, and the two are sequential. | +| `ladder` | Today's behaviour: `responder_plan` in `tinyhivemind-core` selects one responder off the real ladder and that agent answers alone. One deliberation turn — but two model calls when the ladder takes the `Select` rung, since the router has to be asked who answers before anybody does, and the two are sequential. | | `vote` | The honest matched-budget control — independent answers decided by plurality, nobody seeing anybody. It is given the *whole* budget, more turns than the deliberation actually spends. | | `hive` | A deliberation episode at `EpisodePolicy::DEFAULT`. | | `hive+` | The same, at the tuned policy: a majority quorum that is never unanimity, and three turns of budget per member. | diff --git a/crates/tinyhivemind-core/examples/bench/arms.rs b/crates/tinyhivemind-core/examples/bench/arms.rs index 44cb912d..4206838f 100644 --- a/crates/tinyhivemind-core/examples/bench/arms.rs +++ b/crates/tinyhivemind-core/examples/bench/arms.rs @@ -6,7 +6,7 @@ //! arms over the *same* rooms and the same private evaluations: //! //! - **ladder** — today's behaviour. One message selects exactly one responder -//! off the deterministic ladder in `tinyhivemind`, that agent answers, and the +//! off the deterministic ladder in `tinyhivemind-core`, that agent answers, and the //! interaction ends. One turn. //! - **vote** — self-consistency at a matched turn budget. The same number of //! turns the episode was allowed, each spent on one member's independent diff --git a/crates/tinyhivemind-core/examples/bench/cli/README.md b/crates/tinyhivemind-core/examples/bench/cli/README.md new file mode 100644 index 00000000..29906bd7 --- /dev/null +++ b/crates/tinyhivemind-core/examples/bench/cli/README.md @@ -0,0 +1,10 @@ +# `cli/` + +Command-line parsing for the bench binary. The flag table itself is in +`main.rs`'s crate doc. + +| File | Purpose | +| --- | --- | +| `mod.rs` | `Options`, the parsed argument set every other module reads, and `Mode`, what `--sweep`, `--trace`, `--swarm` and the other modes pick between. | +| `flags.rs` | The per-flag handlers `Options::parse` delegates to, grouped by what they configure: scale, expertise, and the live backends. | +| `test.rs` | Flag parsing regressions: fidelity and fact-noise validation, and how `--api-base` interacts with the explicit modes. | diff --git a/crates/tinyhivemind-core/examples/bench/horizon/README.md b/crates/tinyhivemind-core/examples/bench/horizon/README.md new file mode 100644 index 00000000..b623cd68 --- /dev/null +++ b/crates/tinyhivemind-core/examples/bench/horizon/README.md @@ -0,0 +1,10 @@ +# `horizon/` + +The long-horizon sweep (`--stages N`): a chain of sub-decisions decided by the +same members, where context accumulates and a wrong stage poisons the next. +See [`../HORIZON.md`](../HORIZON.md) for the method. + +| File | Purpose | +| --- | --- | +| `mod.rs` | The staged rooms, the `solo`, `solo+fold`, `hive+` and `hive+pooled` arms, and the end-to-end report. | +| `test.rs` | Distinct option names per stage, inherited windows, poisoning, the soloist's whole brief, and that a chain is never likelier than one of its stages. | diff --git a/crates/tinyhivemind-core/examples/bench/live_swarm/README.md b/crates/tinyhivemind-core/examples/bench/live_swarm/README.md new file mode 100644 index 00000000..aa1bd17d --- /dev/null +++ b/crates/tinyhivemind-core/examples/bench/live_swarm/README.md @@ -0,0 +1,10 @@ +# `live_swarm/` + +Driving a live federation of desks (`--swarm`) through a real backend: several +desks, each seated over a CLI agent or the HTTP backend, that reach each other +only through a referral. See [`../SWARM.md`](../SWARM.md). + +| File | Purpose | +| --- | --- | +| `mod.rs` | The drivers: what runs, from simulated federations to live desks and one traced episode. | +| `totals.rs` | What a federated comparison counts and how it prints: per-arm outcomes, running totals, the arm fan-out, and the table. | diff --git a/crates/tinyhivemind-core/examples/bench/main.rs b/crates/tinyhivemind-core/examples/bench/main.rs index 8cf9556f..f3b52049 100644 --- a/crates/tinyhivemind-core/examples/bench/main.rs +++ b/crates/tinyhivemind-core/examples/bench/main.rs @@ -19,7 +19,7 @@ //! the right answer is to pool what its members separately believe. Four arms //! decide the same rooms from the same private evaluations: //! -//! - `ladder` — the responder ladder in `tinyhivemind` selects one responder and +//! - `ladder` — the responder ladder in `tinyhivemind-core` selects one responder and //! that agent answers alone. One turn. This is today's behaviour. //! - `vote` — the honest matched-budget control: independent answers decided by //! plurality, with nobody seeing anybody. It is given the whole budget, which diff --git a/crates/tinyhivemind-core/examples/bench/parallel/README.md b/crates/tinyhivemind-core/examples/bench/parallel/README.md new file mode 100644 index 00000000..478b34a8 --- /dev/null +++ b/crates/tinyhivemind-core/examples/bench/parallel/README.md @@ -0,0 +1,8 @@ +# `parallel/` + +Tests for [`../parallel.rs`](../parallel.rs), the scoped-thread fan-out that +spreads per-room loops across cores without changing a number. + +| File | Purpose | +| --- | --- | +| `test.rs` | Results come back in input order at every job count and whatever order threads finish in; the first error in input order is reported; short slices, panicking closures, and the mutable map are covered. | diff --git a/crates/tinyhivemind-core/examples/bench/scenario/README.md b/crates/tinyhivemind-core/examples/bench/scenario/README.md new file mode 100644 index 00000000..d52dacaa --- /dev/null +++ b/crates/tinyhivemind-core/examples/bench/scenario/README.md @@ -0,0 +1,9 @@ +# `scenario/` + +Tests for [`../scenario.rs`](../scenario.rs), the file format for a real +problem a live room solves, with the private facts behind it. The shipped +scenarios live in [`../scenarios/`](../scenarios/). + +| File | Purpose | +| --- | --- | +| `test.rs` | Every shipped scenario parses, records an answer that is on offer, and gives every member something of its own. | diff --git a/crates/tinyhivemind-core/examples/bench/swarm/format/README.md b/crates/tinyhivemind-core/examples/bench/swarm/format/README.md new file mode 100644 index 00000000..7367caab --- /dev/null +++ b/crates/tinyhivemind-core/examples/bench/swarm/format/README.md @@ -0,0 +1,8 @@ +# `swarm/format/` + +Tests for [`../format.rs`](../format.rs), the wire text a swarm's members +exchange across a referral and the trace line a run keeps. + +| File | Purpose | +| --- | --- | +| `test.rs` | Reading and fact clauses round-trip without disturbing each other, several desks can rule out several options, and an unowned disqualification is dropped. | diff --git a/crates/tinyhivemind-core/examples/bench/variety/README.md b/crates/tinyhivemind-core/examples/bench/variety/README.md new file mode 100644 index 00000000..7655bd54 --- /dev/null +++ b/crates/tinyhivemind-core/examples/bench/variety/README.md @@ -0,0 +1,10 @@ +# `variety/` + +The variety sweep (`--facets F`): one task made of several independent +sub-decisions at once, each needing a different kind of attention. See +[`../VARIETY.md`](../VARIETY.md). + +| File | Purpose | +| --- | --- | +| `mod.rs` | The faceted rooms, facet ownership, the arms, and the all-facets report. | +| `test.rs` | Distinct option names per facet and against stages, owners reading their facet more tightly, and what one seat holds against a soloist. | diff --git a/crates/tinyhivemind-core/examples/driver_bench/README.md b/crates/tinyhivemind-core/examples/driver_bench/README.md index 5cf8f19e..f27f0f19 100644 --- a/crates/tinyhivemind-core/examples/driver_bench/README.md +++ b/crates/tinyhivemind-core/examples/driver_bench/README.md @@ -1,4 +1,4 @@ -# `bench` +# `driver_bench` What the completion driver's policy costs, measured rather than asserted, with no model and no credential. @@ -9,8 +9,8 @@ no model and no credential. | `sim.rs` | a stochastic room whose turns land at different times, and a router that returns well-formed evaluations so real acceptance runs | ```sh -cargo run --release -p tinyhivemind-openhuman --example bench -cargo run --release -p tinyhivemind-openhuman --example bench -- --episodes 2000 --members 8 +cargo run --release -p tinyhivemind-core --example driver_bench +cargo run --release -p tinyhivemind-core --example driver_bench -- --episodes 2000 --members 8 ``` Every arm runs the same seeded rooms through the same `CompletionDriver`, so a diff --git a/crates/tinyhivemind-core/src/aside/README.md b/crates/tinyhivemind-core/src/aside/README.md index 2061ddf8..a285ea6d 100644 --- a/crates/tinyhivemind-core/src/aside/README.md +++ b/crates/tinyhivemind-core/src/aside/README.md @@ -29,7 +29,7 @@ imposes no ceiling on members or messages, and `must_surface` and Two rules give the mechanism its shape and neither is enforced here, because neither is a decision this fold could make: -- **A redaction is a row, not an absence.** `tinyhivemind`'s projection elides +- **A redaction is a row, not an absence.** The runtime's projection elides content and keeps the row, so a reader outside an audience still sees that the exchange happened, who wrote it and to whom. That is what makes an aside auditable rather than a covert channel. diff --git a/crates/tinyhivemind-core/src/driver/conduct/test/README.md b/crates/tinyhivemind-core/src/driver/conduct/test/README.md new file mode 100644 index 00000000..276a3fdc --- /dev/null +++ b/crates/tinyhivemind-core/src/driver/conduct/test/README.md @@ -0,0 +1,15 @@ +# `driver/conduct/test/` + +Unit tests for the conductor, driven by a host that is only an in-memory +journal. `mod.rs` declares the behaviour groups below; each file is one group. + +| File | Purpose | +| --- | --- | +| `mod.rs` | Module doc and the submodule declarations. | +| `support.rs` | Shared fixtures: a hive of named seats, a router that always asks for clarification, the journal, and one wave driven the way a host drives it. | +| `door.rs` | Who starts an episode: routed starters, a task at sequence zero, refused starters, and every plan shape. | +| `desk.rs` | Desk work: stalled-seat nudges, placed and unplaced broadcasts, handoffs, the broadcast budget, the turn wall, and the commit protocol. | +| `conversations.rs` | Conversations opened by an ask: running first, concluding to the asker, group asks, silent askees, refused replies, and thread prefetch. | +| `parked.rs` | Parked seats held by the host, released where they stopped, and snapshots taken and refused between waves. | +| `links.rs` | The links a host reads back to draw the desk: conversation roots on rows, and the rows events name. | +| `wire.rs` | Pinned wire forms of turns, notes, commits, events, refusals, snapshots, and the conduct policy. | diff --git a/crates/tinyhivemind-core/src/driver/engine/brief/README.md b/crates/tinyhivemind-core/src/driver/engine/brief/README.md new file mode 100644 index 00000000..83c6fa6c --- /dev/null +++ b/crates/tinyhivemind-core/src/driver/engine/brief/README.md @@ -0,0 +1,8 @@ +# `driver/engine/brief/` + +Tests for [`../brief.rs`](../brief.rs), which renders what the episode itself +has to tell a seat before its turn. + +| File | Purpose | +| --- | --- | +| `test.rs` | The brief says what the episode knows and only that: the assignment and the parent a seat must name, concluded and in-progress conversations with their transcripts, thread and group-thread briefs, the standing contract, elsewhere context, and naming seats by name or id. | diff --git a/crates/tinyhivemind-core/src/hive/exchange/README.md b/crates/tinyhivemind-core/src/hive/exchange/README.md index 695807a3..1f220c4e 100644 --- a/crates/tinyhivemind-core/src/hive/exchange/README.md +++ b/crates/tinyhivemind-core/src/hive/exchange/README.md @@ -74,7 +74,7 @@ folded; rounds are carried", below, for why that one cannot come out of the log. which members it names, and how many rows the episode may still write. It does no waiting and **chooses no peer**: who a member wants to contact is that member's judgement, and the audience it writes is validated by -`tinyhivemind::aside::aside` exactly as an aside riding alongside a turn is. +`tinyhivemind_core::aside::aside` exactly as an aside riding alongside a turn is. `ExchangeRound::Open::members` is in desk order and contains only members that are active on the episode's desk and still under their own contact cap. A host diff --git a/crates/tinyhivemind-core/src/runtime/README.md b/crates/tinyhivemind-core/src/runtime/README.md index 4dcf3f75..a12d83f6 100644 --- a/crates/tinyhivemind-core/src/runtime/README.md +++ b/crates/tinyhivemind-core/src/runtime/README.md @@ -1,4 +1,4 @@ -# `tinyhivemind` feature modules +# `runtime` feature modules One directory per feature area, wired together and re-exported from [`mod.rs`](mod.rs). Each answers a different question a host needs answered diff --git a/crates/tinyhivemind-core/src/runtime/error/README.md b/crates/tinyhivemind-core/src/runtime/error/README.md index d1b2096b..15cc4716 100644 --- a/crates/tinyhivemind-core/src/runtime/error/README.md +++ b/crates/tinyhivemind-core/src/runtime/error/README.md @@ -4,7 +4,7 @@ The crate-wide `Error` and `Result`. ## Why it exists -Every fallible public function in `tinyhivemind` returns the same `Result`, +Every fallible public function in core's `runtime` module returns the same `Result`, so a host writes one match arm, not one per module. The enum names the ways a *host* implementation of a port can violate its contract, including bad pages and stale cursors. It also reports an overflowing present set, a digest diff --git a/crates/tinyhivemind-core/tests/migrated_runtime/README.md b/crates/tinyhivemind-core/tests/migrated_runtime/README.md index a1f0fb9e..acafef80 100644 --- a/crates/tinyhivemind-core/tests/migrated_runtime/README.md +++ b/crates/tinyhivemind-core/tests/migrated_runtime/README.md @@ -1,6 +1,6 @@ # Runtime integration tests -These tests use only the public `tinyhivemind` API. +These tests use only the public `tinyhivemind_core::runtime` API. | File | Coverage | | --- | --- | diff --git a/crates/tinyhivemind-hives/src/error.rs b/crates/tinyhivemind-hives/src/error.rs index 6d5bed03..9f42d1ba 100644 --- a/crates/tinyhivemind-hives/src/error.rs +++ b/crates/tinyhivemind-hives/src/error.rs @@ -98,7 +98,7 @@ pub enum Error { /// Snapshot wire representation failed. #[error(transparent)] Json(#[from] serde_json::Error), - /// SQLite operation failed. + /// `SQLite` operation failed. #[cfg(feature = "sqlite")] #[error(transparent)] Sqlite(#[from] rusqlite::Error), diff --git a/crates/tinyhivemind-hives/src/lib.rs b/crates/tinyhivemind-hives/src/lib.rs index d37eb69d..f8de377d 100644 --- a/crates/tinyhivemind-hives/src/lib.rs +++ b/crates/tinyhivemind-hives/src/lib.rs @@ -4,7 +4,7 @@ //! hives using the existing core conductor. The host supplies [`AgentRunner`] //! handles; they never enter [`StoredState`]. [`Storage`] is a replaceable //! async port that commits a bounded state row plus append-only transcript -//! rows, with [`MemoryStorage`] and a default-feature SQLite implementation +//! rows, with [`MemoryStorage`] and a default-feature `SQLite` implementation //! provided. //! //! ``` diff --git a/crates/tinyhivemind-hives/src/storage/sqlite.rs b/crates/tinyhivemind-hives/src/storage/sqlite.rs index 2a04fcfa..414d9926 100644 --- a/crates/tinyhivemind-hives/src/storage/sqlite.rs +++ b/crates/tinyhivemind-hives/src/storage/sqlite.rs @@ -1,4 +1,4 @@ -//! Version-two SQLite storage: one state row plus an append-only transcript +//! Version-two `SQLite` storage: one state row plus an append-only transcript //! table, both written in one immediate transaction. use super::{Commit, Storage, StorageFuture, StoredState, TranscriptRow, validate}; use crate::{Error, Message, Result, SendMessage}; @@ -6,9 +6,9 @@ use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, use std::{collections::BTreeMap, path::Path, sync::Mutex}; const SCHEMA_VERSION: i64 = 2; -/// SQLite-backed durable storage, enabled by the default `sqlite` feature. +/// `SQLite`-backed durable storage, enabled by the default `sqlite` feature. /// -/// Its operations are synchronous SQLite calls inside the returned futures: +/// Its operations are synchronous `SQLite` calls inside the returned futures: /// short local transactions, run on whichever executor polls them. #[derive(Debug)] pub struct SqliteStorage { @@ -18,7 +18,7 @@ impl SqliteStorage { /// Open or initialize a version-two database, migrating a version-one /// whole-snapshot database by moving its transcript into rows. /// # Errors - /// Returns SQLite errors or rejects an unsupported schema version. + /// Returns `SQLite` errors or rejects an unsupported schema version. pub fn open(path: impl AsRef) -> Result { let mut connection = Connection::open(path)?; connection.execute_batch( diff --git a/crates/tinyhivemind-hives/src/storage/sqlite_test.rs b/crates/tinyhivemind-hives/src/storage/sqlite_test.rs index 4184695c..85d74585 100644 --- a/crates/tinyhivemind-hives/src/storage/sqlite_test.rs +++ b/crates/tinyhivemind-hives/src/storage/sqlite_test.rs @@ -1,4 +1,4 @@ -//! SQLite reopen, competing writers, schema validation and v1 migration. +//! `SQLite` reopen, competing writers, schema validation and v1 migration. // Panicking assertions are confined to deterministic test fixtures. #![allow(clippy::unwrap_used, clippy::panic)] use super::super::test::{commit, contract, row, state_at}; diff --git a/crates/tinyhivemind-lang/Cargo.toml b/crates/tinyhivemind-lang/Cargo.toml index 5629edd9..4272989b 100644 --- a/crates/tinyhivemind-lang/Cargo.toml +++ b/crates/tinyhivemind-lang/Cargo.toml @@ -21,3 +21,24 @@ sha2.workspace = true [lints] workspace = true + +# Discovered automatically like the other examples; declared only so that +# `cargo test` runs its offline tests (the gallery runs, the pinned frame wire +# form, the 50-seat turn-budget check) rather than just building it. +[[example]] +name = "hive_view" +path = "examples/hive_view/main.rs" +test = true + +# Discovered automatically like the other examples; declared so that +# `cargo test` runs its offline tests of the command-line check. +[[example]] +name = "hive_check" +path = "examples/hive_check/main.rs" +test = true + +# Declared so that `cargo test` runs the self-edit example offline. +[[example]] +name = "self_edit" +path = "examples/self_edit.rs" +test = true diff --git a/crates/tinyhivemind-lang/README.md b/crates/tinyhivemind-lang/README.md index 5eb595d6..7d18e585 100644 --- a/crates/tinyhivemind-lang/README.md +++ b/crates/tinyhivemind-lang/README.md @@ -11,12 +11,19 @@ activation, and `lower` to obtain existing core desks and routing candidates with declared roles. Lowered seat configuration preserves memory constraints; the host must enforce those when registering a runner. +Editors change a hive by editing its files. Call `check` with the original +and edited files: like a compiler, it reports every violation of the language +and the immutable constitution as a located diagnostic, or returns the edit as +one patch per edit class for lineage. The `hive_check` example runs the same +check from the command line. See the +[checking specification](../../docs/specs/hive-language-checking.md). + The immutable constitution pins evaluator settings and hard caps. The host -must use guarded patch application before activating an editor proposal. +must check every edit, or guard every patch it applies, before activation. Accepted and rejected evaluated candidates remain host-owned lineage records. | Directory | Purpose | | --- | --- | | src | Typed wire definitions, validation, lowering, edits and lineage. | | tests | Checked-in package fixtures and public API integration evidence. | -| examples | Deterministic host-driven self-edit demonstration. | +| examples | Self-edit demonstrations, the `hive_check` command, the `hives/` package gallery, and the `hive_view` viewer. | diff --git a/crates/tinyhivemind-lang/examples/README.md b/crates/tinyhivemind-lang/examples/README.md index d6dd5ac4..78138e34 100644 --- a/crates/tinyhivemind-lang/examples/README.md +++ b/crates/tinyhivemind-lang/examples/README.md @@ -2,24 +2,53 @@ | File | Purpose | | --- | --- | +| `hive_check/` | The compiler-style check an orchestrator runs after editing a package's files: every finding as `file:pointer: rule: message`, non-zero exit on any | +| `hive_view/` | Render a package (text, mermaid, JSON) and run it through core's `hive::step`, one floor per desk linked by referral, offline or `--live` on OpenRouter; NDJSON frames for the viewer; the math `--compare` | +| `hives/` | The example package gallery: seven complete packages, from a three-seat review board to a fifty-seat math swarm | | `live_self_edit/` | Provider-failure regression tests and their directory guide | -| `live_self_edit.rs` | Opt-in OpenRouter proposal, exact-match held-out evaluation, negative control and telemetry/contamination guard checks | -| `self_edit.rs` | Propose a prompt delta, guard the candidate, supply a host evaluation, and archive accepted and rejected designs | +| `live_self_edit.rs` | Opt-in OpenRouter editor returning whole files, `check`ed by the host; exact-match held-out evaluation, negative control and telemetry/contamination refusals | +| `self_edit.rs` | An editor returns whole files, the host `check`s them, evaluates the candidate, and archives one lineage record per derived change | +| `self_edit/` | The self-edit example's offline test and its directory guide | Run from the repository root: ```sh cargo run -p tinyhivemind-lang --example self_edit +cargo test -p tinyhivemind-lang --example self_edit ``` -The example uses a deterministic toy rubric: a prompt containing `evidence` -scores higher. This demonstrates host ownership of evaluation and acceptance; +## Checking an edit + +```sh +cargo run -p tinyhivemind-lang --example hive_check -- [--json] +cargo run -p tinyhivemind-lang --example hive_check -- +``` + +An orchestrator edits a copy of a package and runs this; exit status 1 means +at least one finding. See [`hive_check/README.md`](hive_check/README.md). + +The self-edit example's editor returns whole files rather than a patch; one +edit that also raises `max_turns` in the constitution is refused with a +located diagnostic. The example uses a deterministic toy rubric: a prompt +containing `evidence` scores higher. This demonstrates host ownership of evaluation and acceptance; it does not measure model quality or execute the benchmark harness. The archive retains a rejected candidate while `accepted_head` selects the last accepted candidate. Neither evaluation nor archive persistence opens a store or network. A real host substitutes its held-out evaluation and persists candidate snapshots with their records, including persistent memory watermarks when present. +## Viewing and running a package + +```sh +cargo run -p tinyhivemind-lang --example hive_view -- \ + crates/tinyhivemind-lang/examples/hives/review-board --run +cargo test -p tinyhivemind-lang --example hive_view +``` + +[`hive_view/README.md`](hive_view/README.md) documents every mode, the wire +format the viewer reads, and the measured math `--compare` result. +[`hives/README.md`](hives/README.md) lists the gallery. + ## Live model check ```sh @@ -34,10 +63,11 @@ excluded from artifacts. Every model request, response, candidate and lineage record is saved below the chosen output directory, which must be different for each run. -The incumbent deliberately contains a status-filter bug. The editor sees that +The live editor returns the whole new `seats/solver.md`, and the host runs +`check` on it. The incumbent deliberately contains a status-filter bug. The editor sees that training feedback, but not the three held-out invoice inputs. The host checks numeric answers exactly, requires all three to pass, and rejects a deliberately -broken prompt after evaluating it with the same live model. It also requests a telemetry mutation and checks a contamination canary. +broken prompt after evaluating it with the same live model. It also asks the model to return a `hive.json` with telemetry disabled, which `check` must refuse as `immutable`, and checks a contamination canary. A provider refusal is retained explicitly; the host then tests the requested mutation fixture itself and records its origin. This is a small integration smoke test, not evidence of general self-improvement on SWE tasks. diff --git a/crates/tinyhivemind-lang/examples/hive_check/README.md b/crates/tinyhivemind-lang/examples/hive_check/README.md new file mode 100644 index 00000000..ecfd5987 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_check/README.md @@ -0,0 +1,18 @@ +# hive_check + +A compiler-style check for hive packages, for an orchestrator that edits a +package's files directly. + +```sh +cargo run -p tinyhivemind-lang --example hive_check -- [--json] +cargo run -p tinyhivemind-lang --example hive_check -- +cargo test -p tinyhivemind-lang --example hive_check +``` + +Exit status 0 means clean, 1 means at least one finding, 2 a usage or read +error. Dot-files are skipped when a directory is loaded. + +| File | Purpose | +| --- | --- | +| `main.rs` | Argument parsing, directory loading, `check`/`diagnose_documents`, and rendering | +| `test.rs` | Offline tests against the gallery's `self-improving` package | diff --git a/crates/tinyhivemind-lang/examples/hive_check/main.rs b/crates/tinyhivemind-lang/examples/hive_check/main.rs new file mode 100644 index 00000000..ffb7a546 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_check/main.rs @@ -0,0 +1,144 @@ +//! Check a hive package, or an edit of one, the way a compiler checks source. +//! +//! ```sh +//! cargo run -p tinyhivemind-lang --example hive_check -- [] [--json] +//! ``` +//! +//! With one directory it diagnoses that package. With two it checks the +//! second as an edit of the first. Every finding prints as one line, +//! `file:pointer: rule: message`, or with `--json` as one JSON array; the +//! exit status is 1 when there is any finding and 2 for a usage error. This +//! is what an orchestrator runs after editing a package's files. +use std::{ + collections::BTreeMap, + io::Write, + path::{Path, PathBuf}, + process::ExitCode, +}; +use tinyhivemind_lang::{Diagnostic, check, diagnose_documents}; + +type Result = std::result::Result>; + +const USAGE: &str = "usage: hive_check [] [--json]"; + +/// Parsed command line: one or two package directories and the output form. +#[derive(Debug, PartialEq)] +struct Args { + dirs: Vec, + json: bool, +} + +fn args(raw: &[String]) -> Result { + let mut parsed = Args { + dirs: vec![], + json: false, + }; + for arg in raw { + match arg.as_str() { + "--json" => parsed.json = true, + flag if flag.starts_with('-') => return Err(format!("unknown flag {flag}").into()), + dir => parsed.dirs.push(dir.into()), + } + } + if parsed.dirs.is_empty() || parsed.dirs.len() > 2 { + return Err("expected one or two package directories".into()); + } + Ok(parsed) +} + +/// Read every file below `dir` into a map keyed by `/`-separated relative +/// path, skipping dot-files and dot-directories. +fn load(dir: &Path) -> Result> { + let mut files = BTreeMap::new(); + let mut pending = vec![(dir.to_path_buf(), String::new())]; + while let Some((path, prefix)) = pending.pop() { + for entry in std::fs::read_dir(&path)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with('.') { + continue; + } + let key = format!("{prefix}{name}"); + if entry.file_type()?.is_dir() { + pending.push((entry.path(), format!("{key}/"))); + } else { + files.insert(key, std::fs::read_to_string(entry.path())?); + } + } + } + Ok(files) +} + +/// Every finding for one package, or for an edit of `original`, and the +/// classes an accepted edit changed. +fn findings( + original: &BTreeMap, + edited: Option<&BTreeMap>, +) -> (Vec, Vec) { + let Some(edited) = edited else { + return (diagnose_documents(original).1, vec![]); + }; + match check(original, edited) { + Ok(review) => { + let classes = review + .changes + .iter() + .map(|change| { + serde_json::to_value(change.class) + .ok() + .and_then(|class| class.as_str().map(str::to_owned)) + .unwrap_or_default() + }) + .collect(); + (vec![], classes) + } + Err(diagnostics) => (diagnostics, vec![]), + } +} + +/// Render findings as compiler lines or a JSON array. +fn render(diagnostics: &[Diagnostic], classes: &[String], json: bool) -> Result { + if json { + return Ok(serde_json::to_string_pretty(diagnostics)? + "\n"); + } + let mut text = String::new(); + for diagnostic in diagnostics { + text.push_str(&diagnostic.to_string()); + text.push('\n'); + } + let summary = match (diagnostics.len(), classes.is_empty()) { + (0, true) => "ok: no findings".to_owned(), + (0, false) => format!("ok: changes {}", classes.join(", ")), + (1, _) => "1 finding".to_owned(), + (count, _) => format!("{count} findings"), + }; + text.push_str(&summary); + text.push('\n'); + Ok(text) +} + +/// Run the check for `raw` arguments, writing the report to `out`; report +/// whether the package or edit is clean. +fn run(raw: &[String], out: &mut impl Write) -> Result { + let args = args(raw)?; + let original = load(&args.dirs[0])?; + let edited = args.dirs.get(1).map(|dir| load(dir)).transpose()?; + let (diagnostics, classes) = findings(&original, edited.as_ref()); + out.write_all(render(&diagnostics, &classes, args.json)?.as_bytes())?; + Ok(diagnostics.is_empty()) +} + +fn main() -> ExitCode { + let raw: Vec = std::env::args().skip(1).collect(); + match run(&raw, &mut std::io::stdout()) { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::from(1), + Err(error) => { + eprintln!("hive_check: {error}\n{USAGE}"); + ExitCode::from(2) + } + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinyhivemind-lang/examples/hive_check/test.rs b/crates/tinyhivemind-lang/examples/hive_check/test.rs new file mode 100644 index 00000000..70c2f225 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_check/test.rs @@ -0,0 +1,98 @@ +//! Offline checks of the command line: arguments, loading a gallery package, +//! and the rendered report for a refused and an accepted edit. +#![allow(clippy::expect_used)] // Tests fail loudly on unexpected results. +use super::*; + +fn gallery() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/hives/self-improving") +} + +fn strings(args: &[&str]) -> Vec { + args.iter().map(|arg| (*arg).to_owned()).collect() +} + +#[test] +fn parses_one_or_two_directories_and_the_json_flag() { + assert_eq!( + args(&strings(&["a", "--json", "b"])).expect("valid arguments"), + Args { + dirs: vec!["a".into(), "b".into()], + json: true + } + ); + for bad in [&[][..], &["a", "b", "c"], &["a", "--verbose"]] { + assert!(args(&strings(bad)).is_err(), "{bad:?}"); + } +} + +#[test] +fn loads_a_package_directory_with_nested_paths() { + let files = load(&gallery()).expect("the gallery package loads"); + assert!(files.contains_key("hive.json")); + assert!(files.contains_key("seats/solver.md")); + assert!(files.contains_key("context/playbook.md")); + assert!(load(&gallery().join("missing")).is_err()); +} + +#[test] +fn a_clean_package_and_an_unchanged_edit_exit_clean() { + let dir = gallery().to_string_lossy().into_owned(); + let mut out = Vec::new(); + assert!(run(&strings(&[&dir]), &mut out).expect("runs")); + assert_eq!(String::from_utf8(out).expect("utf-8"), "ok: no findings\n"); + let mut out = Vec::new(); + assert!(run(&strings(&[&dir, &dir, "--json"]), &mut out).expect("runs")); + assert_eq!(String::from_utf8(out).expect("utf-8"), "[]\n"); + assert!(run(&strings(&["--json"]), &mut Vec::new()).is_err()); +} + +#[test] +fn a_refused_edit_prints_one_line_per_finding() { + let original = load(&gallery()).expect("loads"); + let mut edited = original.clone(); + let hive = edited["hive.json"] + .replace("\"turn_budget\": 9", "\"turn_budget\": 12") + .replace( + "\"role\": \"solver\",\n \"goals\"", + "\"role\": \"ghost\",\n \"goals\"", + ); + edited.insert("hive.json".into(), hive); + let (diagnostics, classes) = findings(&original, Some(&edited)); + assert_eq!(classes.len(), 0, "{classes:?}"); + let text = render(&diagnostics, &classes, false).expect("renders"); + assert_eq!( + text, + "hive.json:/missions/0/role: reference: unresolved role reference ghost\n\ + hive.json:/policies/episode/turn_budget: forbidden_class: the constitution does not \ + permit policy edits\n\ + 2 findings\n" + ); + let json = render(&diagnostics[..1], &classes, true).expect("renders"); + let back: Vec = serde_json::from_str(&json).expect("json array"); + assert_eq!(back, diagnostics[..1]); + assert_eq!( + render(&diagnostics[..1], &[], false).expect("renders"), + format!("{}\n1 finding\n", diagnostics[0]) + ); +} + +#[test] +fn an_accepted_edit_names_the_classes_it_changed() { + let original = load(&gallery()).expect("loads"); + let mut edited = original.clone(); + edited.insert( + "seats/solver.md".into(), + format!( + "{}Include canceled invoices.\n", + original["seats/solver.md"] + ), + ); + let (diagnostics, classes) = findings(&original, Some(&edited)); + assert_eq!(diagnostics.len(), 0, "{diagnostics:?}"); + assert_eq!( + render(&diagnostics, &classes, false).expect("renders"), + "ok: changes prompt\n" + ); + let (diagnostics, _) = findings(&edited, None); + assert_eq!(diagnostics.len(), 0, "{diagnostics:?}"); +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/README.md b/crates/tinyhivemind-lang/examples/hive_view/README.md new file mode 100644 index 00000000..212fd03e --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/README.md @@ -0,0 +1,299 @@ +# hive_view + +Look at a hive package, and watch it run, offline or against a real model. +`hive_view` reads a package directory, hands the strings to +`tinyhivemind_lang::parse`, lowers it, and then renders it or drives it +through core's `hive::step`. The example does the file IO (and, with +`--live`, shells out to `curl`); the library stays pure, so +`.github/scripts/assert-pure.sh` is unchanged. + +```sh +H=crates/tinyhivemind-lang/examples/hives +cargo run -p tinyhivemind-lang --example hive_view -- $H/review-board # text tree +cargo run -p tinyhivemind-lang --example hive_view -- $H/incident-response --mermaid +cargo run -p tinyhivemind-lang --example hive_view -- $H/math-swarm --json +cargo run -p tinyhivemind-lang --example hive_view -- $H/review-board --run [--trace out.jsonl] +cargo run -p tinyhivemind-lang --example hive_view -- $H/math-panel --run --frames --seed 3 +cargo run -p tinyhivemind-lang --example hive_view -- $H/math-swarm --compare --seeds 50 --table +OPENROUTER_API_KEY=... cargo run -p tinyhivemind-lang --example hive_view -- $H/math-swarm --run --frames --live --max-calls 300 +cargo test -p tinyhivemind-lang --example hive_view +``` + +`--table` prints the comparison as a table (with wall-clock milliseconds per +episode) instead of the NDJSON stream; `--run --compare` is the same as +`--compare`. + +## How a run works + +- **One floor per desk.** A one-desk package runs on that desk. A package of + several desks runs one episode per desk: its own journal, its own + `EpisodeState`, and `hive::step` over that desk's members only. Each desk + takes its share of the package's quorum threshold and turn budget (for + math-swarm, 6 of 10 and 30 of 150), widths capped at its size. +- **Core decides.** Each step is `hive::step` over a desk's transcript; each + authorized seat speaks against `project_for` (so a blind or concurrent seat + sees only what core allows); its line is appended; repeat. The ending is the + `HiveStep` core returns. Standings in frames come from `trace::read` and + `quorum::standings`, counting only the desk's members, as `step` does. +- **Who speaks what.** `script.json` (seat id to lines) is replayed when + present; past the end of its lines a seat writes `!question`. Without one, a + seeded default script proposes one of `option-a`/`b`/`c` blind, then + supports the leader (60%), objects to a rival's advocate (25%) or adds + evidence (15%). A package with `sim.json` runs the math simulation. In the + commit phase every speaker records the topic that carried in its own view. + With `--live` a model writes every line (below). +- **`--trace`** writes core telemetry (`Stamped` `TraceEvent`s) as JSONL with + a one-tick-per-event clock, the format `examples/lab/viewer` reads. + `TraceEvent` carries no message content, which is why frames exist. + +## Desk routing + +`desks.rs` is the bench federation's host (`tinyhivemind-core/examples/bench/ +swarm/`) on a package's own desks: a pass gives every unfinished desk one +move — answer a referral that arrived, or take its next round. Messages cross +desks through core's `referral` fold only: the mention is read by core's +mention grammar, and the routing, the hop budget and the answer's return edge +are `referral(policy, input, roster, desks)`. The host adds what +`ReferralPolicy` leaves to a host: + +- **Links.** A referral travels only along a declared, directed `topology` or + `referrals` link; a return travels back along its forward's link. Anything + else is refused `unlinked`. A desk that has ended refuses `desk_closed`. +- **Pipeline.** A link from an earlier-declared desk to a later one is forward. + A desk opens once every desk with a forward link into it has ended, so + math-swarm runs `conjecture`, `compute`, `prove`, `verify`, `synthesis` in + turn. The last declared desk's ending is the hive's. +- **Reports.** A converged desk's committing seat refers its answer down each + outgoing link: `!propose # ^1 settled on ...` and `@#`. + The row lands in the receiving journal authored by that seat, so core never + counts it there; members must back it in their own turns. A report carries + the deepest hop that reached its desk, so `max_hops` bounds the pipeline: + at math-swarm's 4, synthesis's feedback reports are refused + `hop_limit_reached`. A desk that did not converge reports nothing. +- **Asks.** A seat line with an `@#desk` (or an off-desk agent) mention is a + forward referral starting at hop 0. The seat core chooses answers on its own + desk (a round frame with phase `answer`), and core's `Return` carries the + answer home. A desk may ask at most twice (`FORWARD_CAP`): a constant width + bound, as `ReferralPolicy`'s documentation asks of a host. The simulation + never asks; live models may. +- **Policy.** `policies.referral` is honoured as core reads it: `enabled: + false` refuses every report `disabled`, `reach: local` refuses desk + mentions, `returns: false` keeps answers on the far desk. + +A simulated seat reads a report as one more advocate in its own view (it +can support or object to it); core's quorum never counts it. + +## Live mode (`--live`) + +`--live` makes every seat turn a real chat completion on OpenRouter, playing +the seat: its prompt (the persona line and job), its role charter, its desk +and linked desks, the trace grammar, and **its own projection** of its desk's +journal — referral rows included, blind rounds still blind. Core still decides +every round and ending; `desks.rs` still routes every referral. + +- **Key.** `OPENROUTER_API_KEY` from the environment, handed to `curl` as a + `--config -` header on stdin, so it is in no argv, file or log. +- **Model.** `--model `, else `OPENROUTER_MODEL`, else + `openai/gpt-oss-120b:nitro` (what `live_self_edit.rs` uses). +- **Replies.** Reduced deterministically to the first line core's trace reader + accepts with its required fields (bullets, emphasis, code fences and topic + punctuation such as `#1,287.` repaired), plus at most one `@#desk` line. A + reply with no valid trace line is recorded as `note: ...`, which never votes. +- **Concurrency.** A round's calls run on scoped threads, at most + `round_width` at once; replies land in the order core authorized. +- **Budget.** `--max-calls N` (default 300, at most 2000) is a hard cap: a + round the remaining calls cannot fill ends the run `exhausted`. + `--max-tokens N` (default 1200) per call. A `usage` frame reports calls, + tokens and the cost OpenRouter returned. An HTTP 400 to 404 (a bad request, + an unknown model, or an auth, payment or permission refusal) ends the run + with the redacted error; any other failed call (a timeout, a 429 or 5xx + after curl's retries) is recorded as a `note:` line. A run the cap stopped + says so on stderr and in `usage.exhausted_calls`. +- **Artifacts.** `hive-runs/hive-view/-seed-/` at the root of + the main checkout (git-ignored; outside `target/`, so `cargo clean` keeps + it; and, built in a linked worktree, under the checkout that worktree's + `.git` file points back to, so removing the worktree keeps it), or `$HIVE_RUNS_DIR/-seed-/` when + `HIVE_RUNS_DIR` is set: every request body and response, `calls.ndjson` + (seat, desk, raw reply, recorded line), `usage.json`, and the recording + (below). Never the key. + +### Recordings (`--record`) + +A live run, and an offline run given `--record`, is kept as a recording in +its run folder: `structure.json` (the `--json` structure, written before the +first frame) and `frames.ndjson` (every frame, flushed line by line as it is +written, also in human `--run` mode). `examples/lab/viewer` lists every +recording under `hive-runs/` and follows one that is still being written. +Stdout is the same with or without a recording. + +### No web access + +Enforced three times. Every gallery seat denies `web_search`, `web_fetch`, +`browser` and `http` and allows none (`tests/gallery_packages.rs`); the one +exception is `euler-hive`, whose research-desk seats opt in to `web_search` +and nothing else. The only +request body sent carries no `tools`, `tool_choice`, `functions`, `plugins` or +`web_search_options` (pinned in `test/live.rs`, and checked again before each +send). And a model id with `online`, `search`, `sonar` or `web` in it (such +as `:online`) is refused before any call. + +Personas are cosmetic offline: the simulation draws behaviour from +`sim.json`, so the offline numbers do not depend on them. + +## The math simulation + +Every seat draws an accuracy from its role's `sim.json` range (parts per +million) with the seed, then derives a private answer for the problem: right +with that probability, otherwise the problem's classic mistake (50%) or the +answer plus or minus one (25% each). Answers are computed in `math.rs`. + +| Behaviour | Blind round | Once the room is revealed | +| --- | --- | --- | +| `guess` | Propose its guess | Support the leading topic once | +| `solve` | Propose its derivation | Object to one advocate of a rival answer per turn | +| `check` | Propose its derivation | Object to up to two advocates of rival answers per turn | +| `slice` | Propose the exact answer, or post its residue | Object to up to two advocates its residue rules out; support the leading admitted answer once | +| `lead` | Propose its derivation | Support the leading topic whenever it changes | + +Objections are `!object >N ^M`, grounded in the seat's own proposal; support +is `!support #T ^N`, citing a proposal. The ranges are shared by every math +package and were fixed before the first run: a swarm seat is no better than +its panel counterpart. The single-seat baseline is one `solve` seat on a +one-member desk with a quorum of one. + +### Result, 50 seeds + +`--compare --seeds 50 --table`, release build, one floor per desk: + +```text +package seats accuracy converged deadlocked exhausted idle rounds turns ms/episode +single-seat 1 0.640 1.000 0.000 0.000 0.000 2.00 2.00 0.010 +math-panel 5 0.580 0.720 0.000 0.280 0.000 2.30 8.62 0.051 +math-swarm 50 0.980 1.000 0.000 0.000 0.000 12.54 84.90 1.395 +``` + +The swarm converged on every seed and was right on 49 of 50, in a mean of 85 +turns over its five desks. Run on one combined floor before desks were routed +it was right on all 50 in 54 turns: the pipeline spends more turns (every desk +holds its own blind round) and its five desks decide one after another, each +on a quorum of six. The five-seat panel did *worse* than one seat: its errors +are correlated (half of all wrong derivations land on the same classic +mistake), a wrong checker silences correct advocates, and 28% of its episodes +exhausted their fifteen turns with every advocate silenced. These are +simulated seats; this measures the protocol under a stated error model, not +model quality. + +## Wire format (`--json`, `--run --frames`, `--compare`) + +This is the contract the viewer UI is built against. Fields may be added; +none may be renamed or removed. Additions so far: `seats[].memory`, +`episode.quorum_window`, `episode.require_grounded`, `missions`, +`sim.scripted`, `turns[].sequence`, `standings[].supporters`, +`results[].topic`, `rows[].deadlocked`/`exhausted`/`idle`, `round.desk`, +`round.desk_round`, `desks[].episode`, phase `answer`, the `referral`, `desk_end` and `usage` +frames, `end.desk`, and `start.live`/`start.model`. `correct` in an +`end` frame is `false` (not `null`) for a math run that did not decide. +`standings[].object` counts advocates silenced by a grounded objection. + +Binary: `cargo run -p tinyhivemind-lang --example hive_view -- [mode] [knobs]` +Built binary path used by the viewer: `/target/debug/examples/hive_view`. + +Modes: + +- (none) — text tree of the lowered package +- `--mermaid` — mermaid flowchart +- `--json` — ONE JSON object (structure), see below +- `--run` — human-readable episode lines + ending +- `--run --frames` — NDJSON frames on stdout (one JSON object per line) +- `--run --trace ` — also write core telemetry Stamped TraceEvents as JSONL +- `--run --record` — also keep the run as a recording (`structure.json` + `frames.ndjson`; implied by `--live`) +- `--compare --seeds N [--packages a,b,...]` — NDJSON compare stream (math packages; always includes baseline "single-seat") + +Knobs: `--seed N` (u64, default 1), `--round-width N` (1..=64), `--turn-budget N` +(1..=2000), `--problem ` (math only). `--round-width` sets both the blind +and the revealed width; on several desks `--turn-budget` is the hive's total, +shared out. Live: `--live` (with `--run`), `--model `, `--max-calls N` +(1..=2000), `--max-tokens N` (64..=8192). Errors: non-zero exit, message on +stderr. + +### `--json` structure + +```text +{ + "id": "math-swarm", "name": "Math swarm", "summary": "", + "roles": [{"id":"verifier","charter":""}], + "seats": [{"id":"verify-03","label":"verify-03","role":"verifier","desks":["verify"]}], + "desks": [{"id":"verify","name":"Verify","description":"...","members":["verify-01",...],"responder_mode":"auto", + "episode":{"turn_budget":30,"round_width":10,"revealed_width":10,"quorum_threshold":6}}], // this desk's floor + "topology": [{"from":"conjecture","to":"compute"}], + "referrals": [{"from":"verify","to":"synthesis"}], + "goals": [{"id":"...","description":"...","subgoals":[]}], + "episode": {"turn_budget":120,"round_width":10,"revealed_width":10,"blind_round":true,"quorum_threshold":6}, + "sim": {"kind":"math","problems":[{"id":"lattice-6x6","statement":"..."}]} // or {"kind":"script"} +} +``` + +### `--run --frames` NDJSON + +```text +{"type":"start","package":"math-swarm","seed":3,"seats":50,"problem":{"id":"lattice-6x6","statement":"...","answer":"924"}} // problem null for non-math + // --live adds "live":true,"model":"" +{"type":"round","round":1,"desk":"verify","desk_round":1,"phase":"deliberate"|"commit"|"answer","visibility":"blind"|"full", + "turns":[{"seat":"verify-03","desk":"verify","reason":"","saw":4,"of":9,"content":"!object 920 ...", + "answer":"920","correct":false}], // answer/correct null when not math or no answer in line + "standings":[{"topic":"924","support":5,"object":1,"committed":false}]} // the desk's members only +{"type":"referral","round":10,"kind":"report"|"forward"|"return","from_desk":"verify","to_desk":"synthesis", + "seat":"verify-01","target":"synthesis-01"|null,"content":"!propose #924 ^1 ...\n@#synthesis","hops":4, + "accepted":true|false,"reason":"hop_limit_reached"|"unlinked"|"desk_closed"|"width_cap"|"disabled"|..., // reason only when refused + "sequence":2|null} // where it landed in to_desk's journal +{"type":"desk_end","desk":"verify","ending":"converged"|...|"unopened","topic":"924"|null,"correct":true|null,"rounds":2,"turns":11} +... +{"type":"usage","calls":58,"prompt_tokens":40070,"completion_tokens":6034,"total_tokens":46104,"cost_usd":0.01855,"wall_ms":14525,"exhausted_calls":false} // --live only +{"type":"end","ending":"converged"|"deadlocked"|"exhausted"|"idle","topic":"924"|null,"correct":true|null,"rounds":7,"turns":58,"desk":"synthesis"} +``` + +`round` counts rounds across every desk. A `referral` frame's `round` is that +counter when it was routed; `hops` is the child hop it travelled at, or the +hop it was refused at. `desk_end` frames (one per desk, several desks only) +come before `end`, whose `desk` (several desks only) names the desk whose +ending it is. Core's refusal reasons are `NoReferralReason` in `snake_case`; +`unlinked`, `desk_closed` and `width_cap` are this host's. + +### `--compare` NDJSON + +```text +{"type":"seed","seed":1,"problem":"lattice-6x6","results":[{"package":"single-seat","seats":1,"ending":"converged","correct":false,"rounds":1,"turns":1},{"package":"math-panel",...},{"package":"math-swarm",...}]} +... +{"type":"summary","seeds":50,"rows":[{"package":"math-swarm","seats":50,"accuracy":0.86,"converged":0.94,"mean_rounds":6.2,"mean_turns":51.0}]} +``` + +## Files + +| File | Purpose | +| --- | --- | +| `main.rs` | Argument parsing and mode dispatch | +| `load.rs` | Read a package directory into the `parse` map; README summary; `sim.json`/`script.json` | +| `render.rs` | Text tree and mermaid flowchart | +| `structure.rs` | The `--json` structure | +| `episode.rs` | One desk's room, the `Speaker` trait, and the `hive::step` loop | +| `hive.rs` | A package of several desks: one room per desk, each desk's share of the policy, the declared links | +| `desks.rs` | The pass scheduler that runs one floor per desk and routes referrals between them | +| `script.rs` | `script.json` replay and the seeded default script | +| `math.rs` | The problem bank and its exact answers and classic mistakes | +| `sim.rs` | Seat behaviours for math packages | +| `rng.rs` | Seeded `SplitMix64` | +| `run.rs` | Knobs, plans (one room or a hive), the single-seat baseline | +| `output.rs` | `--run` output (human or frames), the recording, the JSONL trace sink, live usage | +| `halt.rs` | The flag an observer raises, and the speaker wrapper that ends the run at the next round when a recording write fails | +| `frames.rs` | The NDJSON wire types | +| `compare.rs` | `--compare` | +| `live/` | `--live`: the OpenRouter transport, prompts and reply parsing, the live speaker | +| `test/` | Offline tests, including the pinned wire form | + +Custom math problems are bounded before computation: `primes` accepts limits +through 100,000, `multiples` through 1,000,000, `sqdiff` through 10,000, and +`modsum` accepts at most 1,000,000 units of `n * max(power, 1)` work. Larger +operands are refused during package validation, including gallery listings. + +Referral answers consume the same hive-wide turn capacity as desk rounds; +when the asking turn spends the last slot, its answer cannot launch. diff --git a/crates/tinyhivemind-lang/examples/hive_view/compare.rs b/crates/tinyhivemind-lang/examples/hive_view/compare.rs new file mode 100644 index 00000000..f02be3c5 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/compare.rs @@ -0,0 +1,246 @@ +//! `--compare`: the single-seat baseline and each math package over the same +//! seeds and problems, scored against the exact answers. + +use std::io::Write; +use std::path::Path; +use std::time::{Duration, Instant}; + +use crate::Result; +use crate::episode::{Ending, Outcome}; +use crate::frames::{CompareFrame, SeedResult, SummaryRow}; +use crate::load::{Loaded, load}; +use crate::math::problems; +use crate::run::{Knobs, Prepared, baseline, correct, execute, pick, prepare}; + +/// The packages `--compare` runs when `--packages` is not given. +pub(crate) const DEFAULT_PACKAGES: [&str; 2] = ["math-panel", "math-swarm"]; + +/// The baseline's package name in every result. +pub(crate) const BASELINE: &str = "single-seat"; + +#[derive(Debug, Default)] +struct Tally { + package: String, + seats: usize, + correct: u32, + endings: [u32; 4], + rounds: u64, + turns: u64, + elapsed: Duration, +} + +impl Tally { + fn add(&mut self, result: &SeedResult, elapsed: Duration) { + self.correct += u32::from(result.correct); + let slot = match result.ending.as_str() { + "converged" => 0, + "deadlocked" => 1, + "exhausted" => 2, + _ => 3, + }; + self.endings[slot] += 1; + self.rounds += u64::from(result.rounds); + self.turns += u64::from(result.turns); + self.elapsed += elapsed; + } + + fn row(&self, seeds: u32) -> SummaryRow { + let rate = |count: u32| ratio(f64::from(count), seeds); + let mean = |total: u64| ratio(f64::from(u32::try_from(total).unwrap_or(u32::MAX)), seeds); + SummaryRow { + package: self.package.clone(), + seats: self.seats, + accuracy: rate(self.correct), + converged: rate(self.endings[0]), + deadlocked: rate(self.endings[1]), + exhausted: rate(self.endings[2]), + idle: rate(self.endings[3]), + mean_rounds: mean(self.rounds), + mean_turns: mean(self.turns), + } + } +} + +/// `value / seeds`, rounded to three decimals. +fn ratio(value: f64, seeds: u32) -> f64 { + (value / f64::from(seeds.max(1)) * 1000.0).round() / 1000.0 +} + +fn result(package: &str, mut prepared: Prepared) -> Result<(SeedResult, Duration)> { + let started = Instant::now(); + let outcome: Outcome = execute(&mut prepared, None, &mut |_| {})?; + let elapsed = started.elapsed(); + let correct = correct(&outcome, prepared.problem.as_ref()).unwrap_or(false); + Ok(( + SeedResult { + package: package.to_owned(), + seats: prepared.seats(), + ending: outcome.ending.name().to_owned(), + correct, + rounds: outcome.rounds, + turns: outcome.turns, + topic: match &outcome.ending { + Ending::Converged(topic) => Some(topic.clone()), + _ => None, + }, + }, + elapsed, + )) +} + +/// Resolve `--packages` names as siblings of `dir` (or `dir` itself). +/// +/// # Errors +/// +/// Fails when a package does not load or carries no `sim.json`. +pub(crate) fn packages(dir: &Path, names: &[String]) -> Result> { + let parent = dir.parent().unwrap_or(dir); + names + .iter() + .map(|name| { + let path = if dir.file_name().is_some_and(|own| own == name.as_str()) { + dir.to_path_buf() + } else { + parent.join(name) + }; + let loaded = load(&path)?; + if loaded.sim.is_none() { + return Err(format!("{name} has no sim.json; --compare runs math packages").into()); + } + Ok(loaded) + }) + .collect() +} + +/// Run the comparison and write NDJSON (or a table) to `out`. +/// +/// The problem bank and the baseline come from the first package; every +/// package must hold the problem each seed picks. +/// +/// # Errors +/// +/// Fails on any load or run error. +pub(crate) fn compare( + loaded: &[Loaded], + seeds: u32, + knobs: &Knobs, + table: bool, + out: &mut dyn Write, +) -> Result> { + let first = loaded + .first() + .ok_or("--compare needs at least one package")?; + let bank = problems( + first + .sim + .as_ref() + .ok_or("the first package has no sim.json")?, + )?; + let banks = loaded + .iter() + .map(|package| { + let sim = package + .sim + .as_ref() + .ok_or_else(|| format!("{} has no sim.json", package.lowered.id))?; + Ok((package.lowered.id.as_str(), problems(sim)?)) + }) + .collect::>>()?; + let mut tallies: Vec = std::iter::once(BASELINE.to_owned()) + .chain(loaded.iter().map(|package| package.lowered.id.clone())) + .map(|package| Tally { + package, + ..Tally::default() + }) + .collect(); + for seed in 1..=u64::from(seeds) { + let problem = pick(&bank, seed, knobs.problem.as_deref())?; + let knobs = Knobs { + seed, + problem: Some(problem.id.clone()), + ..knobs.clone() + }; + for (package, own) in &banks { + if let Some(theirs) = own.iter().find(|other| other.id == problem.id) + && theirs != &problem + { + return Err(format!( + "{package} defines problem {} differently from {}; --compare needs the same problem in every package", + problem.id, first.lowered.id + ) + .into()); + } + } + let mut results = vec![result(BASELINE, baseline(first, &knobs)?)?]; + for package in loaded { + results.push(result(&package.lowered.id, prepare(package, &knobs)?)?); + } + for (tally, (result, elapsed)) in tallies.iter_mut().zip(&results) { + tally.seats = result.seats; + tally.add(result, *elapsed); + } + if !table { + let frame = CompareFrame::Seed { + seed, + problem: problem.id.clone(), + results: results.into_iter().map(|(result, _)| result).collect(), + }; + writeln!(out, "{}", serde_json::to_string(&frame)?)?; + } + } + let rows: Vec = tallies.iter().map(|tally| tally.row(seeds)).collect(); + if table { + print_table(out, &tallies, &rows, seeds)?; + } else { + let summary = CompareFrame::Summary { + seeds: u64::from(seeds), + rows: rows.clone(), + }; + writeln!(out, "{}", serde_json::to_string(&summary)?)?; + } + Ok(rows) +} + +fn print_table( + out: &mut dyn Write, + tallies: &[Tally], + rows: &[SummaryRow], + seeds: u32, +) -> Result<()> { + writeln!( + out, + "{seeds} seeds, same problem per seed for every package; correct = exact answer\n" + )?; + writeln!( + out, + "{:<12} {:>5} {:>8} {:>9} {:>10} {:>9} {:>6} {:>7} {:>7} {:>11}", + "package", + "seats", + "accuracy", + "converged", + "deadlocked", + "exhausted", + "idle", + "rounds", + "turns", + "ms/episode" + )?; + for (tally, row) in tallies.iter().zip(rows) { + let millis = tally.elapsed.as_secs_f64() * 1000.0 / f64::from(seeds.max(1)); + writeln!( + out, + "{:<12} {:>5} {:>8.3} {:>9.3} {:>10.3} {:>9.3} {:>6.3} {:>7.2} {:>7.2} {:>11.3}", + row.package, + row.seats, + row.accuracy, + row.converged, + row.deadlocked, + row.exhausted, + row.idle, + row.mean_rounds, + row.mean_turns, + millis + )?; + } + Ok(()) +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/desks.rs b/crates/tinyhivemind-lang/examples/hive_view/desks.rs new file mode 100644 index 00000000..9fa75727 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/desks.rs @@ -0,0 +1,638 @@ +//! Several desks, one floor each, and the referrals that cross between them. +//! +//! This is the bench federation's host (`crates/tinyhivemind-core/examples/ +//! bench/swarm/`) on a package's own desks: one journal and one +//! `EpisodeState` per desk, `hive::step` over that desk's members only, and a +//! pass scheduler that gives every unfinished desk one move per pass — answer +//! a referral that arrived, or take the round its own episode authorized. +//! Messages cross desks through core's real `referral` fold and nowhere else: +//! the mention is read with core's mention grammar, the routing is +//! `referral(policy, input, roster, desks)`, and the answer's way home is the +//! fold's `Return`. What this host adds is what `ReferralPolicy` leaves to a +//! host: the package's declared links, and a width bound. +//! +//! - **Links.** A referral may travel only along a declared desk link +//! (`topology` or `referrals`, directed). A return travels back along the +//! link its forward took. Anything else is refused as `unlinked`. +//! - **Pipeline.** A link from an earlier-declared desk to a later one is a +//! *forward* link. A desk with forward links into it opens once every desk +//! upstream of it has ended, so it deliberates with their reports in hand. +//! The last declared desk answers for the hive. A link back to an earlier +//! desk carries feedback; it can only land if that desk is still open. +//! - **Reports.** When a desk converges, its committing seat refers the +//! settled answer to each desk it links to, one referral per link, as +//! `!propose # ^1` plus an `@#desk` mention. The row lands in the +//! receiving desk's journal authored by the reporting seat, so core never +//! counts it there: a member has to back it in its own turn. +//! - **Asks.** Any seat line with an `@#desk` (or an off-desk agent) mention +//! is a referral too. The receiving desk's chosen seat answers on its own +//! desk and the answer returns to the asking desk. A desk may make at most +//! [`FORWARD_CAP`] asks, a constant rather than a share of its peers. +//! - **Hops.** A report carries the deepest hop that reached its desk, so a +//! pipeline's depth is bounded by `max_hops`; an ask starts at hop zero. + +use std::collections::VecDeque; + +use tinyhivemind_core::dispatch::{DispatchConversation, DispatchKey}; +use tinyhivemind_core::hive::{ + Conversation, EpisodeState, HiveStep, Sequence, SessionAuthor, SessionMessage, desk::DeskSet, + roster::Roster, step, +}; +use tinyhivemind_core::mention::{MentionAuthor, MentionTarget, resolve as mentions_in}; +use tinyhivemind_core::referral::{ + Referral, ReferralDecision, ReferralInput, ReferralKind, ReferralOrigin, referral, +}; +use tinyhivemind_core::telemetry::Tracer; + +use crate::Result; +use crate::episode::{ + Ending, Event, Outcome, RoundRecord, Speaker, ending_of, fold, member_ids, message, play, + speech, +}; +use crate::hive::Hive; + +/// Asks one desk may refer to other desks across its episode. +pub(crate) const FORWARD_CAP: u32 = 2; + +/// One referral as the run routed it, or refused it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ReferralRecord { + /// The run's round counter when it was routed. + pub(crate) round: u32, + /// `report`, `forward` or `return`. + pub(crate) kind: &'static str, + /// The desk it left. + pub(crate) from_desk: String, + /// The desk it was for. + pub(crate) to_desk: String, + /// The seat that wrote it. + pub(crate) seat: String, + /// The seat core chose to receive it, when it chose one. + pub(crate) target: Option, + /// What it carried. + pub(crate) content: String, + /// Its depth: the child hop for a routed one, the hop it stopped at for a + /// refused one. + pub(crate) hops: u32, + /// Whether it landed. + pub(crate) accepted: bool, + /// Why not, when it did not: a core `NoReferralReason` in `snake_case`, + /// or `unlinked`, `desk_closed` or `width_cap` from this host. + pub(crate) reason: Option, + /// The sequence it landed at in the receiving desk's journal. + pub(crate) sequence: Option, +} + +/// How one desk's own episode ended. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct DeskEnd { + /// The desk. + pub(crate) desk: String, + /// Its ending. + pub(crate) ending: Ending, + /// Rounds it took. + pub(crate) rounds: u32, + /// Turns its seats took, including referral answers. + pub(crate) turns: u32, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Sent { + Report, + Line, +} + +#[derive(Debug)] +struct Floor { + journal: Vec, + state: Option, + ended: Option, + rounds: u32, + turns: u32, + hop: u32, + forwards: u32, + pending: VecDeque, +} + +/// One line a referral may be read out of. +struct Line<'a> { + author: &'a str, + content: &'a str, + sequence: u64, + hop: u32, + origin: Option, + sent: Sent, +} + +struct Federation<'h, 'o, 't, 's> { + hive: &'h Hive, + floors: Vec, + round: u32, + /// Shared capacity includes both authorized rounds and referral answers. + turns_left: u64, + tracer: Option<&'t Tracer<'s>>, + observe: &'o mut dyn FnMut(Event<'_>), +} + +/// Run every desk of `hive` to termination, routing referrals between them. +/// +/// The hive's ending is the last declared desk's; a metered speaker that runs +/// out ends every unfinished desk (and so the hive) exhausted. +/// +/// # Errors +/// +/// Fails when core rejects a snapshot or policy, or the speaker fails. +pub(crate) fn run_hive( + hive: &Hive, + speaker: &mut dyn Speaker, + tracer: Option<&Tracer<'_>>, + observe: &mut dyn FnMut(Event<'_>), +) -> Result { + let floors = hive + .rooms + .iter() + .map(|room| Floor { + journal: vec![message(1, SessionAuthor::Operator, &room.opening)], + state: None, + ended: None, + rounds: 0, + turns: 0, + hop: 0, + forwards: 0, + pending: VecDeque::new(), + }) + .collect(); + let mut federation = Federation { + hive, + floors, + round: 0, + turns_left: hive + .rooms + .iter() + .map(|room| u64::from(room.policy.turn_budget)) + .sum(), + tracer, + observe, + }; + let spent = federation.drive(speaker)?; + let answer = &federation.floors[hive.answer()]; + let ending = match answer.ended.clone() { + Some(Ending::Unopened) | None => Ending::Exhausted, + Some(ending) => ending, + }; + Ok(Outcome { + ending, + rounds: federation.round, + turns: federation.floors.iter().map(|floor| floor.turns).sum(), + spent, + }) +} + +impl Federation<'_, '_, '_, '_> { + /// Returns whether a metered speaker's budget stopped the run. + fn drive(&mut self, speaker: &mut dyn Speaker) -> Result { + let count = self.floors.len(); + let mut spent = false; + loop { + let mut progressed = false; + for desk in 0..count { + if self.floors[desk].ended.is_some() { + // Answers owed by a desk that has finished are stranded. + self.floors[desk].pending.clear(); + continue; + } + if let Some(incoming) = self.floors[desk].pending.pop_front() { + if self.turns_left == 0 || speaker.remaining() == Some(0) { + spent = true; + break; + } + self.deliver(desk, &incoming, speaker)?; + progressed = true; + continue; + } + if self.floors[desk].state.is_none() { + let ready = self + .hive + .upstream(desk) + .iter() + .all(|from| self.floors[*from].ended.is_some()); + if !ready { + continue; + } + self.open(desk); + } + if !self.take_round(desk, speaker)? { + spent = true; + break; + } + progressed = true; + } + if spent || !progressed { + break; + } + } + for desk in 0..count { + if self.floors[desk].ended.is_none() { + let ending = if self.floors[desk].state.is_some() { + Ending::Exhausted + } else { + Ending::Unopened + }; + self.finish(desk, ending)?; + } + } + Ok(spent) + } + + fn open(&mut self, desk: usize) { + let room = &self.hive.rooms[desk]; + let floor = &mut self.floors[desk]; + let watermark = floor + .journal + .last() + .map_or(Sequence(0), |message| message.sequence); + floor.state = Some(EpisodeState::opened( + Conversation { + desk_id: room.desk.id.clone(), + desk_name: room.desk.name.clone(), + thread_root: None, + }, + watermark, + )); + } + + /// One `step` on one desk: speak its round, or end it. Returns `false` + /// when a metered speaker cannot fill the round. + fn take_round(&mut self, desk: usize, speaker: &mut dyn Speaker) -> Result { + let hive = self.hive; + let room = &hive.rooms[desk]; + let Some(state) = self.floors[desk].state.clone() else { + return Ok(true); + }; + let decision = { + let roster = Roster::new(&hive.everyone, &[], &[]); + let desk_set = DeskSet::new(&hive.desks, &[], &[], &[], &[]); + step( + &state, + &self.floors[desk].journal, + &roster, + &desk_set, + &room.policy, + )? + }; + if let Some(tracer) = self.tracer { + tracer.step(&decision); + } + if let Some(ending) = ending_of(&decision) { + self.finish(desk, ending)?; + return Ok(true); + } + let HiveStep::Speak { turns, next_state } = decision else { + return Ok(true); + }; + let width = u64::try_from(turns.len()).unwrap_or(u64::MAX); + if self.turns_left < width { + return Ok(false); + } + if speaker + .remaining() + .is_some_and(|left| (left as usize) < turns.len()) + { + return Ok(false); + } + self.turns_left -= width; + self.round += 1; + let floor = &mut self.floors[desk]; + floor.rounds += 1; + let records = play(room, &turns, &mut floor.journal, speaker)?; + floor.turns += u32::try_from(records.len()).unwrap_or(u32::MAX); + floor.state = Some(*next_state); + let (standings, committed) = fold(&floor.journal, &room.policy, &member_ids(room))?; + let record = RoundRecord { + round: self.round, + desk: room.desk.id.clone(), + desk_round: floor.rounds, + phase: turns.first().map(|turn| turn.phase), + visibility: turns + .first() + .map_or(tinyhivemind_core::hive::Visibility::Blind, |turn| { + turn.visibility + }), + turns: records, + standings, + committed, + }; + (self.observe)(Event::Round(&record)); + for turn in &record.turns { + self.route( + desk, + Line { + author: &turn.seat, + content: &turn.content, + sequence: turn.sequence, + hop: 0, + origin: None, + sent: Sent::Line, + }, + )?; + } + Ok(true) + } + + /// The seat a forward chose answers on its own desk; the answer goes back. + fn deliver( + &mut self, + desk: usize, + incoming: &Referral, + speaker: &mut dyn Speaker, + ) -> Result<()> { + let hive = self.hive; + let room = &hive.rooms[desk]; + let visible = self.floors[desk].journal.clone(); + self.turns_left -= 1; + let content = speaker.answer(&incoming.target_id, &incoming.content, &visible)?; + let floor = &mut self.floors[desk]; + let sequence = next_sequence(&floor.journal); + let of = floor.journal.len(); + floor + .journal + .push(speech(sequence, &incoming.target_id, &content)); + floor.turns += 1; + self.round += 1; + let (standings, committed) = fold(&floor.journal, &room.policy, &member_ids(room))?; + let record = RoundRecord { + round: self.round, + desk: room.desk.id.clone(), + desk_round: floor.rounds, + phase: None, + visibility: tinyhivemind_core::hive::Visibility::Full, + turns: vec![crate::episode::TurnRecord { + seat: incoming.target_id.clone(), + desk: room.home_of(&incoming.target_id), + reason: "referral".into(), + saw: visible.len(), + of, + content: content.clone(), + sequence, + }], + standings, + committed, + }; + (self.observe)(Event::Round(&record)); + self.route( + desk, + Line { + author: &incoming.target_id, + content: &content, + sequence, + hop: incoming.child_hop, + origin: incoming.origin.clone(), + sent: Sent::Line, + }, + ) + } + + /// Record a desk's ending and send its settled answer down its links. + fn finish(&mut self, desk: usize, ending: Ending) -> Result<()> { + let floor = &mut self.floors[desk]; + floor.ended = Some(ending.clone()); + let end = DeskEnd { + desk: self.hive.rooms[desk].desk.id.clone(), + ending: ending.clone(), + rounds: floor.rounds, + turns: floor.turns, + }; + (self.observe)(Event::DeskEnd(&end)); + let Ending::Converged(topic) = ending else { + return Ok(()); + }; + let room = &self.hive.rooms[desk]; + let (standings, _) = fold(&self.floors[desk].journal, &room.policy, &member_ids(room))?; + let support = standings + .iter() + .find(|standing| standing.topic.0 == topic) + .map_or(0, |standing| standing.supporters.len()); + let author = committer(&self.floors[desk].journal, &topic, &member_ids(room)) + .or_else(|| room.members.first().map(|member| member.id.clone())) + .unwrap_or_default(); + let hop = self.floors[desk].hop; + let from = room.desk.id.clone(); + let reported: Vec = self + .hive + .links + .iter() + .filter(|(a, _)| *a == from) + .map(|(_, to)| to.clone()) + .collect(); + for to in reported { + let content = format!( + "!propose #{topic} ^1 {} settled on {topic} with {support} grounded supporters.\n@#{to}", + room.desk.name + ); + let sequence = next_sequence(&self.floors[desk].journal).saturating_sub(1); + self.route( + desk, + Line { + author: &author, + content: &content, + sequence, + hop, + origin: None, + sent: Sent::Report, + }, + )?; + } + Ok(()) + } + + /// Core's referral decision for one line, and the desk it was meant + /// for; `None` when the line addresses no other desk and answers nothing. + fn decide(&self, desk: usize, line: &Line<'_>) -> Result> { + let hive = self.hive; + let from = &hive.rooms[desk].desk.id; + let roster = Roster::new(&hive.everyone, &[], &[]); + let desk_set = DeskSet::new(&hive.desks, &[], &[], &[], &[]); + let mentions = mentions_in( + line.content, + None, + &MentionAuthor::Agent { + id: line.author.to_owned(), + }, + &roster, + &desk_set, + ); + let intended = mentions + .iter() + .filter(|mention| !mention.quiet) + .find_map(|mention| match &mention.target { + MentionTarget::Desk { id } => Some(id.clone()), + MentionTarget::Agent { id } => hive + .desks + .iter() + .find(|candidate| candidate.members.contains(id)) + .map(|home| home.id.clone()) + .filter(|home| home != from), + MentionTarget::Person { .. } | MentionTarget::Everyone => None, + }) + .or_else(|| { + line.origin + .as_ref() + .map(|origin| origin.conversation.desk_id.clone()) + }); + let Some(intended) = intended else { + return Ok(None); + }; + let input = ReferralInput { + key: DispatchKey { + trigger_sequence: line.sequence, + }, + conversation: DispatchConversation { + desk_id: from.clone(), + thread_root: None, + }, + author_id: line.author.to_owned(), + content: line.content.to_owned(), + mentions, + hop: line.hop, + origin: line.origin.clone(), + }; + Ok(Some(( + referral(hive.referral, &input, &roster, &desk_set)?, + intended, + ))) + } + + /// Ask core whether a line owes one referral, check it against the + /// package's links, and land it. + fn route(&mut self, desk: usize, line: Line<'_>) -> Result<()> { + let Some((decision, intended)) = self.decide(desk, &line)? else { + return Ok(()); + }; + let Line { + author, + content, + hop, + origin, + sent, + .. + } = line; + let hive = self.hive; + let from = hive.rooms[desk].desk.id.clone(); + let mut record = ReferralRecord { + round: self.round, + kind: match sent { + Sent::Report => "report", + Sent::Line if origin.is_some() => "return", + Sent::Line => "forward", + }, + from_desk: from.clone(), + to_desk: intended, + seat: author.to_owned(), + target: None, + content: content.to_owned(), + hops: hop, + accepted: false, + reason: None, + sequence: None, + }; + let one = match decision { + ReferralDecision::None { reason } => { + record.reason = Some(snake(&format!("{reason:?}"))); + (self.observe)(Event::Referral(&record)); + return Ok(()); + } + ReferralDecision::One { referral } => *referral, + }; + // A referral that stays on this desk is already where its reader is. + if !one.crosses() { + return Ok(()); + } + record.to_desk.clone_from(&one.to.desk_id); + record.target = Some(one.target_id.clone()); + record.hops = one.child_hop; + if sent == Sent::Line { + record.kind = match one.kind { + ReferralKind::Forward => "forward", + ReferralKind::Return => "return", + }; + } + let linked = match one.kind { + ReferralKind::Forward => hive.linked(&from, &one.to.desk_id), + ReferralKind::Return => { + hive.linked(&one.to.desk_id, &from) || hive.linked(&from, &one.to.desk_id) + } + }; + let target = hive.index(&one.to.desk_id); + let refusal = match target { + _ if !linked => Some("unlinked"), + None => Some("unknown_desk"), + Some(index) if self.floors[index].ended.is_some() => Some("desk_closed"), + _ if sent == Sent::Line + && one.kind == ReferralKind::Forward + && self.floors[desk].forwards >= FORWARD_CAP => + { + Some("width_cap") + } + _ => None, + }; + let Some(index) = target.filter(|_| refusal.is_none()) else { + record.reason = refusal.map(str::to_owned); + (self.observe)(Event::Referral(&record)); + return Ok(()); + }; + let landed = next_sequence(&self.floors[index].journal); + self.floors[index] + .journal + .push(speech(landed, author, content)); + record.accepted = true; + record.sequence = Some(landed); + match (sent, one.kind) { + (Sent::Report, _) => { + let floor = &mut self.floors[index]; + floor.hop = floor.hop.max(one.child_hop); + } + (Sent::Line, ReferralKind::Forward) => { + self.floors[desk].forwards += 1; + self.floors[index].pending.push_back(one); + } + (Sent::Line, ReferralKind::Return) => {} + } + (self.observe)(Event::Referral(&record)); + Ok(()) + } +} + +/// The member who last committed `topic`, the seat that reports it. +fn committer(journal: &[SessionMessage], topic: &str, members: &[&str]) -> Option { + tinyhivemind_core::hive::trace::read(journal) + .into_iter() + .rev() + .find(|trace| { + trace.kind == tinyhivemind_core::hive::TraceKind::Commit + && trace.topic.as_ref().is_some_and(|id| id.0 == topic) + && trace.agent_id().is_some_and(|id| members.contains(&id)) + }) + .and_then(|trace| trace.agent_id().map(str::to_owned)) +} + +fn next_sequence(journal: &[SessionMessage]) -> u64 { + u64::try_from(journal.len()) + .unwrap_or(u64::MAX) + .saturating_add(1) +} + +/// `HopLimitReached` to `hop_limit_reached`. +pub(crate) fn snake(name: &str) -> String { + let mut out = String::with_capacity(name.len() + 4); + for (index, character) in name.chars().enumerate() { + if character.is_uppercase() { + if index > 0 { + out.push('_'); + } + out.extend(character.to_lowercase()); + } else { + out.push(character); + } + } + out +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/episode.rs b/crates/tinyhivemind-lang/examples/hive_view/episode.rs new file mode 100644 index 00000000..dc7b1274 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/episode.rs @@ -0,0 +1,437 @@ +//! Run one episode of one desk through core's `hive::step`. +//! +//! The loop is `crates/tinyhivemind-core/examples/hive.rs`'s: fold, authorize a +//! round, let each authorized seat speak against its own projection, append, +//! repeat. What a seat says comes from a [`Speaker`]: a script, the seeded +//! default script, the math simulation, or (`--live`) a model. The ending +//! always comes from core — this file never decides who won. A package of +//! several desks runs one of these floors per desk; `desks.rs` schedules them +//! and routes referrals between them. + +use std::collections::BTreeMap; + +use tinyhivemind_core::hive::aside::Audience; +use tinyhivemind_core::hive::{ + Conversation, EpisodePolicy, EpisodeState, HiveStep, HiveTurn, Phase, Sequence, SessionAuthor, + SessionMessage, TopicStanding, TraceKind, Visibility, + desk::{Desk, DeskSet}, + project_for, + quorum::standings, + roster::{Roster, RosterMember}, + step, + trace::read, +}; +use tinyhivemind_core::telemetry::Tracer; +use tinyhivemind_lang::LoweredHive; + +use crate::Result; + +/// Everything one desk's episode runs over. +#[derive(Clone, Debug)] +pub(crate) struct Room { + /// Package id, for reports. + pub(crate) package: String, + /// The desk's seats, as roster members. + pub(crate) members: Vec, + /// The desk the episode runs on. + pub(crate) desk: Desk, + /// Each seat's home desk (its first declared desk). + pub(crate) home: BTreeMap, + /// The episode policy, after knobs. + pub(crate) policy: EpisodePolicy, + /// The operator message that opens the episode. + pub(crate) opening: String, +} + +impl Room { + /// The room for one declared desk of a lowered package, under `policy`. + pub(crate) fn of( + lowered: &LoweredHive, + desk: &Desk, + policy: EpisodePolicy, + opening: String, + ) -> Self { + let mut home = BTreeMap::new(); + for declared in &lowered.desks { + for member in &declared.members { + home.entry(member.clone()) + .or_insert_with(|| declared.id.clone()); + } + } + Self { + package: lowered.id.clone(), + members: desk + .members + .iter() + .map(|id| RosterMember { + id: id.clone(), + name: Some(id.clone()), + }) + .collect(), + desk: desk.clone(), + home, + policy, + opening, + } + } + + /// The desk a seat's turn is reported under: its home desk, or this one. + pub(crate) fn home_of(&self, seat: &str) -> String { + self.home + .get(seat) + .cloned() + .unwrap_or_else(|| self.desk.id.clone()) + } +} + +/// What a seat says when it is authorized to speak. +pub(crate) trait Speaker { + /// The content of one authorized turn, given what that turn may see. + fn speak( + &mut self, + turn: &HiveTurn, + visible: &[SessionMessage], + policy: &EpisodePolicy, + ) -> String; + + /// Every turn of one authorized round, each against its own projection. + /// + /// Sequential by default; a live speaker runs the round's model calls + /// concurrently, which core allows because the projections already + /// withhold every row written beside a turn. + /// + /// # Errors + /// + /// A live speaker fails when a call it cannot do without fails. + fn speak_round( + &mut self, + round: &[(HiveTurn, Vec)], + policy: &EpisodePolicy, + ) -> Result> { + Ok(round + .iter() + .map(|(turn, visible)| self.speak(turn, visible, policy)) + .collect()) + } + + /// What `seat` writes on its desk when a referral from another desk + /// arrives for it. The simulation never asks, so by default it says + /// nothing further. + /// + /// # Errors + /// + /// As [`Speaker::speak_round`]. + fn answer(&mut self, seat: &str, incoming: &str, visible: &[SessionMessage]) -> Result { + let _ = (seat, incoming, visible); + Ok(crate::script::QUIET.to_owned()) + } + + /// Turns this speaker may still fill; `None` when it is not metered. + fn remaining(&self) -> Option { + None + } + + /// This speaker as `Any`, for a report that needs its concrete type. + fn as_any(&self) -> Option<&dyn std::any::Any> { + None + } +} + +/// One turn as it happened. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TurnRecord { + /// The seat. + pub(crate) seat: String, + /// The seat's home desk. + pub(crate) desk: String, + /// Why core gave it the floor. + pub(crate) reason: String, + /// Rows of the transcript the turn could see. + pub(crate) saw: usize, + /// Rows in the transcript when the turn was taken. + pub(crate) of: usize, + /// What it said. + pub(crate) content: String, + /// The sequence its message was appended at. + pub(crate) sequence: u64, +} + +/// One round as it happened, plus the standings once it was appended. +#[derive(Clone, Debug)] +pub(crate) struct RoundRecord { + /// One-based round number, counted across every desk of the run. + pub(crate) round: u32, + /// The desk the round ran on. + pub(crate) desk: String, + /// One-based round number on that desk. + pub(crate) desk_round: u32, + /// The round's phase; `None` for a seat answering a referral. + pub(crate) phase: Option, + /// The round's visibility. + pub(crate) visibility: Visibility, + /// Its turns, in desk order. + pub(crate) turns: Vec, + /// Standings over the transcript after the round. + pub(crate) standings: Vec, + /// Topics with a `!commit` trace after the round. + pub(crate) committed: Vec, +} + +/// How an episode ended, as core reported it. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum Ending { + /// One topic carried and was recorded. + Converged(String), + /// Two or more topics carried and nobody could break the tie. + Deadlocked(Vec), + /// The turn budget ran out. + Exhausted, + /// Nobody bid for the floor. + Idle, + /// The desk never opened: the run stopped before its upstream desks ended. + Unopened, +} + +impl Ending { + /// The `snake_case` name the frames use. + pub(crate) fn name(&self) -> &'static str { + match self { + Self::Converged(_) => "converged", + Self::Deadlocked(_) => "deadlocked", + Self::Exhausted => "exhausted", + Self::Idle => "idle", + Self::Unopened => "unopened", + } + } + + /// The decided topic, when there is one. + pub(crate) fn topic(&self) -> Option<&str> { + match self { + Self::Converged(topic) => Some(topic), + _ => None, + } + } +} + +/// A finished episode. +#[derive(Clone, Debug)] +pub(crate) struct Outcome { + /// How it ended (for several desks: how the answering desk ended). + pub(crate) ending: Ending, + /// Speaking rounds taken, across every desk. + pub(crate) rounds: u32, + /// Turns taken, across every desk, including referral answers. + pub(crate) turns: u32, + /// Whether a metered speaker could not fill the next round (`--max-calls`). + pub(crate) spent: bool, +} + +/// Something a run reports as it happens. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Event<'a> { + /// A round was appended. + Round(&'a RoundRecord), + /// A referral was routed, or refused. + Referral(&'a crate::desks::ReferralRecord), + /// One desk's episode ended. + DeskEnd(&'a crate::desks::DeskEnd), +} + +/// Speak one authorized round against `journal` and append it. +/// +/// Every turn speaks against the journal as it stood when the round was +/// authorized: `project_for` withholds each row a peer writes beside it, so +/// computing every projection first is the same as computing each one as +/// its turn comes up. `of` and `sequence` are what they would be appended one +/// at a time. +/// +/// # Errors +/// +/// As [`Speaker::speak_round`], or when it returns the wrong number of lines. +pub(crate) fn play( + room: &Room, + turns: &[HiveTurn], + journal: &mut Vec, + speaker: &mut dyn Speaker, +) -> Result> { + let base = journal.len(); + let views: Vec<(HiveTurn, Vec)> = turns + .iter() + .map(|turn| (turn.clone(), project_for(turn, journal))) + .collect(); + let said = speaker.speak_round(&views, &room.policy)?; + if said.len() != views.len() { + return Err("a speaker answered a round with the wrong number of lines".into()); + } + let mut records = Vec::with_capacity(views.len()); + for (index, ((turn, visible), content)) in views.iter().zip(said).enumerate() { + let sequence = u64::try_from(base + index + 1).unwrap_or(u64::MAX); + records.push(TurnRecord { + seat: turn.agent_id.clone(), + desk: room.home_of(&turn.agent_id), + reason: format!("{:?}", turn.reason).to_lowercase(), + saw: visible.len(), + of: base + index, + content: content.clone(), + sequence, + }); + journal.push(speech(sequence, &turn.agent_id, &content)); + } + Ok(records) +} + +/// The ending a terminal step reports; `None` for a round to speak. +pub(crate) fn ending_of(decision: &HiveStep) -> Option { + match decision { + HiveStep::Speak { .. } => None, + HiveStep::Converged { topic, .. } => Some(Ending::Converged(topic.0.clone())), + HiveStep::Deadlocked { topics } => Some(Ending::Deadlocked( + topics.iter().map(|topic| topic.0.clone()).collect(), + )), + HiveStep::Exhausted { .. } => Some(Ending::Exhausted), + HiveStep::Idle => Some(Ending::Idle), + } +} + +/// The member ids of a room, for folds that count only members. +pub(crate) fn member_ids(room: &Room) -> Vec<&str> { + room.members + .iter() + .map(|member| member.id.as_str()) + .collect() +} + +/// Drive one desk's episode to termination. +/// +/// `observe` is called once per round, after the round is appended. A +/// metered speaker that cannot fill the next round ends the episode +/// exhausted. +/// +/// # Errors +/// +/// Fails when core rejects the roster, desks or policy, or a speaker fails. +pub(crate) fn run( + room: &Room, + speaker: &mut dyn Speaker, + tracer: Option<&Tracer<'_>>, + observe: &mut dyn FnMut(Event<'_>), +) -> Result { + let mut journal = vec![message(1, SessionAuthor::Operator, &room.opening)]; + let conversation = Conversation { + desk_id: room.desk.id.clone(), + desk_name: room.desk.name.clone(), + thread_root: None, + }; + let mut state = EpisodeState::opened(conversation, Sequence(1)); + let desks = [room.desk.clone()]; + let (mut rounds, mut turns_taken) = (0_u32, 0_u32); + loop { + let roster = Roster::new(&room.members, &[], &[]); + let desk_set = DeskSet::new(&desks, &[], &[], &[], &[]); + let decision = step(&state, &journal, &roster, &desk_set, &room.policy)?; + if let Some(tracer) = tracer { + tracer.step(&decision); + } + if let Some(ending) = ending_of(&decision) { + return Ok(outcome(ending, rounds, turns_taken)); + } + let HiveStep::Speak { turns, next_state } = decision else { + return Ok(outcome(Ending::Idle, rounds, turns_taken)); + }; + if speaker + .remaining() + .is_some_and(|left| (left as usize) < turns.len()) + { + return Ok(Outcome { + spent: true, + ..outcome(Ending::Exhausted, rounds, turns_taken) + }); + } + rounds += 1; + let records = play(room, &turns, &mut journal, speaker)?; + turns_taken += u32::try_from(turns.len()).unwrap_or(u32::MAX); + let (standings, committed) = fold(&journal, &room.policy, &member_ids(room))?; + observe(Event::Round(&RoundRecord { + round: rounds, + desk: room.desk.id.clone(), + desk_round: rounds, + phase: turns.first().map(|turn| turn.phase), + visibility: turns + .first() + .map_or(Visibility::Blind, |turn| turn.visibility), + turns: records, + standings, + committed, + })); + state = *next_state; + } +} + +fn outcome(ending: Ending, rounds: u32, turns: u32) -> Outcome { + Outcome { + ending, + rounds, + turns, + spent: false, + } +} + +/// Standings and committed topics over what the desk's members wrote. +/// +/// The same fold `step` makes: core's `trace::read` and `quorum::standings` +/// over the transcript, at its last sequence, counting only traces by +/// `members` — a referral row from another desk informs, it never votes. +/// +/// # Errors +/// +/// Fails on a malformed quorum policy. +pub(crate) fn fold( + journal: &[SessionMessage], + policy: &EpisodePolicy, + members: &[&str], +) -> Result<(Vec, Vec)> { + let traces: Vec<_> = read(journal) + .into_iter() + .filter(|trace| match &trace.author { + SessionAuthor::Agent { id, .. } => members.contains(&id.as_str()), + _ => true, + }) + .collect(); + let at = journal + .last() + .map_or(Sequence(0), |message| message.sequence); + let standing = standings(&traces, at, &policy.quorum)?; + let mut committed: Vec = Vec::new(); + for trace in &traces { + if trace.kind == TraceKind::Commit + && let Some(topic) = &trace.topic + && !committed.contains(&topic.0) + { + committed.push(topic.0.clone()); + } + } + Ok((standing, committed)) +} + +/// A message from anyone. +pub(crate) fn message(sequence: u64, author: SessionAuthor, content: &str) -> SessionMessage { + SessionMessage { + sequence: Sequence(sequence), + author, + content: content.to_owned(), + audience: Audience::Desk, + elided: None, + } +} + +/// A message from a seat. +pub(crate) fn speech(sequence: u64, id: &str, content: &str) -> SessionMessage { + message( + sequence, + SessionAuthor::Agent { + id: id.to_owned(), + label: id.to_owned(), + }, + content, + ) +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/frames.rs b/crates/tinyhivemind-lang/examples/hive_view/frames.rs new file mode 100644 index 00000000..773d6723 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/frames.rs @@ -0,0 +1,278 @@ +//! The NDJSON wire types `--run --frames` and `--compare` print. +//! +//! These are the viewer's contract (see this directory's README). Fields may be +//! added; none may be renamed or removed. `test.rs` pins each one's JSON. + +use serde::Serialize; +use tinyhivemind_core::hive::{SessionAuthor, TraceKind, trace::read}; + +use crate::desks::{DeskEnd, ReferralRecord}; +use crate::episode::{RoundRecord, TurnRecord, speech}; + +/// The problem a math run poses, with its exact answer. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct ProblemFrame { + pub(crate) id: String, + pub(crate) statement: String, + pub(crate) answer: String, +} + +/// One NDJSON line of a run. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum Frame { + /// First line: what is running. + Start { + package: String, + seed: u64, + seats: usize, + problem: Option, + /// Addition: `true` on a `--live` run, absent otherwise. + #[serde(skip_serializing_if = "Option::is_none")] + live: Option, + /// Addition: the model a `--live` run calls, absent otherwise. + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, + }, + /// One line per speaking round (or per referral answer, phase `answer`). + Round { + /// Counted across every desk of the run. + round: u32, + /// Addition: the desk the round ran on. + desk: String, + /// Addition: the round's number on that desk. + desk_round: u32, + phase: String, + visibility: String, + turns: Vec, + standings: Vec, + }, + /// Addition: a referral between desks, routed or refused. + Referral(ReferralFrame), + /// Addition: one desk's ending, before the final `end` (several desks only). + DeskEnd { + desk: String, + ending: String, + topic: Option, + correct: Option, + rounds: u32, + turns: u32, + }, + /// Addition: what a `--live` run spent, just before `end`. + Usage(UsageFrame), + /// Last line: how core ended the episode. + End { + ending: String, + topic: Option, + correct: Option, + rounds: u32, + turns: u32, + /// Addition: the desk whose ending this is, on a run of several desks. + #[serde(skip_serializing_if = "Option::is_none")] + desk: Option, + }, +} + +/// A referral between desks. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct ReferralFrame { + pub(crate) round: u32, + /// `report`, `forward` or `return`. + pub(crate) kind: String, + pub(crate) from_desk: String, + pub(crate) to_desk: String, + pub(crate) seat: String, + /// The seat core chose to receive it, when it chose one. + pub(crate) target: Option, + pub(crate) content: String, + pub(crate) hops: u32, + pub(crate) accepted: bool, + /// Present only when `accepted` is false. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) reason: Option, + /// Where it landed in the receiving desk's journal; `null` if refused. + pub(crate) sequence: Option, +} + +/// What a `--live` run spent. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(crate) struct UsageFrame { + pub(crate) calls: u64, + pub(crate) prompt_tokens: u64, + pub(crate) completion_tokens: u64, + pub(crate) total_tokens: u64, + /// As `OpenRouter` reported it per call, summed; `0.0` if it reported none. + pub(crate) cost_usd: f64, + pub(crate) wall_ms: u64, + /// Whether `--max-calls` stopped the run. + pub(crate) exhausted_calls: bool, +} + +/// One turn inside a round frame. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct TurnFrame { + pub(crate) seat: String, + pub(crate) desk: String, + pub(crate) reason: String, + pub(crate) saw: usize, + pub(crate) of: usize, + pub(crate) content: String, + /// The topic the line proposes, supports or commits; `None` otherwise. + pub(crate) answer: Option, + /// `answer` against ground truth; `None` when not math or no answer. + pub(crate) correct: Option, + /// The sequence the line was appended at (an addition to the contract). + pub(crate) sequence: u64, +} + +/// One topic's standing after a round. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct StandingFrame { + pub(crate) topic: String, + /// Distinct supporters left after cross-inhibition. + pub(crate) support: usize, + /// Advocates silenced by a grounded objection. + pub(crate) object: usize, + /// Whether a `!commit` names the topic. + pub(crate) committed: bool, + /// The supporters themselves (an addition to the contract). + pub(crate) supporters: Vec, +} + +/// One package's result for one seed of a comparison. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct SeedResult { + pub(crate) package: String, + pub(crate) seats: usize, + pub(crate) ending: String, + pub(crate) correct: bool, + pub(crate) rounds: u32, + pub(crate) turns: u32, + /// The decided topic, when there is one (an addition to the contract). + pub(crate) topic: Option, +} + +/// One package's summary over every seed of a comparison. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub(crate) struct SummaryRow { + pub(crate) package: String, + pub(crate) seats: usize, + pub(crate) accuracy: f64, + pub(crate) converged: f64, + /// Additions to the contract: the other three endings' rates. + pub(crate) deadlocked: f64, + pub(crate) exhausted: f64, + pub(crate) idle: f64, + pub(crate) mean_rounds: f64, + pub(crate) mean_turns: f64, +} + +/// One NDJSON line of `--compare`. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub(crate) enum CompareFrame { + /// Every package on one seed. + Seed { + seed: u64, + problem: String, + results: Vec, + }, + /// Last line: the table. + Summary { seeds: u64, rows: Vec }, +} + +/// The round frame for a round record; `truth` is the math answer, if any. +pub(crate) fn round(record: &RoundRecord, truth: Option<&str>) -> Frame { + Frame::Round { + round: record.round, + desk: record.desk.clone(), + desk_round: record.desk_round, + phase: record + .phase + .map_or_else(|| "answer".to_owned(), |phase| snake(&format!("{phase:?}"))), + visibility: snake(&format!("{:?}", record.visibility)), + turns: record + .turns + .iter() + .map(|turn| turn_frame(turn, truth)) + .collect(), + standings: record + .standings + .iter() + .map(|standing| StandingFrame { + topic: standing.topic.0.clone(), + support: standing.supporters.len(), + object: standing.silenced.len(), + committed: record.committed.contains(&standing.topic.0), + supporters: standing.supporters.clone(), + }) + .collect(), + } +} + +/// The referral frame for a routed or refused referral. +pub(crate) fn referral(record: &ReferralRecord) -> Frame { + Frame::Referral(ReferralFrame { + round: record.round, + kind: record.kind.to_owned(), + from_desk: record.from_desk.clone(), + to_desk: record.to_desk.clone(), + seat: record.seat.clone(), + target: record.target.clone(), + content: record.content.clone(), + hops: record.hops, + accepted: record.accepted, + reason: record.reason.clone(), + sequence: record.sequence, + }) +} + +/// The `desk_end` frame for one desk's ending; `truth` as for [`round`]. +pub(crate) fn desk_end(end: &DeskEnd, truth: Option<&str>) -> Frame { + let topic = end.ending.topic().map(str::to_owned); + Frame::DeskEnd { + desk: end.desk.clone(), + ending: end.ending.name().into(), + correct: truth.map(|truth| topic.as_deref() == Some(truth)), + topic, + rounds: end.rounds, + turns: end.turns, + } +} + +fn turn_frame(turn: &TurnRecord, truth: Option<&str>) -> TurnFrame { + let answer = advocated(&turn.seat, &turn.content); + TurnFrame { + seat: turn.seat.clone(), + desk: turn.desk.clone(), + reason: turn.reason.clone(), + saw: turn.saw, + of: turn.of, + content: turn.content.clone(), + correct: truth + .zip(answer.as_deref()) + .map(|(truth, answer)| truth == answer), + answer, + sequence: turn.sequence, + } +} + +/// The first topic a line proposes, supports or commits, read with core's +/// trace grammar. +pub(crate) fn advocated(seat: &str, content: &str) -> Option { + let message = speech(1, seat, content); + read(std::slice::from_ref(&message)) + .into_iter() + .find(|trace| { + matches!( + trace.kind, + TraceKind::Propose | TraceKind::Support | TraceKind::Commit + ) && matches!(trace.author, SessionAuthor::Agent { .. }) + }) + .and_then(|trace| trace.topic.map(|topic| topic.0)) +} + +/// Lowercase a single-word `Debug` name. +fn snake(name: &str) -> String { + name.to_lowercase() +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/halt.rs b/crates/tinyhivemind-lang/examples/hive_view/halt.rs new file mode 100644 index 00000000..32522489 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/halt.rs @@ -0,0 +1,81 @@ +//! Stopping a run when its recording can no longer be written. +//! +//! An observer cannot return early, so it raises a [`Halt`]; the run's speaker +//! then reports no turns left and the run ends exhausted at the next round +//! boundary, before any further (paid) model call. + +use std::cell::Cell; +use std::rc::Rc; + +use tinyhivemind_core::hive::{EpisodePolicy, HiveTurn, SessionMessage}; + +use crate::Result; +use crate::episode::Speaker; +use crate::run::Prepared; + +/// A flag a run's observer raises to stop the run at its next round. +#[derive(Clone, Debug, Default)] +pub(crate) struct Halt(Rc>); + +impl Halt { + /// Ask the run to stop before it pays for another round. + pub(crate) fn stop(&self) { + self.0.set(true); + } +} + +/// A speaker that reports no turns left once its [`Halt`] is raised. +pub(crate) struct Halting { + inner: Box, + halt: Halt, +} + +/// Put `halt` in charge of `prepared`'s speaker. +pub(crate) fn install(prepared: &mut Prepared, halt: Halt) { + let inner = std::mem::replace(&mut prepared.speaker, Box::new(Silent)); + prepared.speaker = Box::new(Halting { inner, halt }); +} + +/// The placeholder in a [`Prepared`] for the instant its speaker is wrapped. +struct Silent; + +impl Speaker for Silent { + fn speak(&mut self, _: &HiveTurn, _: &[SessionMessage], _: &EpisodePolicy) -> String { + crate::script::QUIET.to_owned() + } +} + +impl Speaker for Halting { + fn speak( + &mut self, + turn: &HiveTurn, + visible: &[SessionMessage], + policy: &EpisodePolicy, + ) -> String { + self.inner.speak(turn, visible, policy) + } + + fn speak_round( + &mut self, + round: &[(HiveTurn, Vec)], + policy: &EpisodePolicy, + ) -> Result> { + self.inner.speak_round(round, policy) + } + + fn answer(&mut self, seat: &str, incoming: &str, visible: &[SessionMessage]) -> Result { + self.inner.answer(seat, incoming, visible) + } + + fn remaining(&self) -> Option { + if self.halt.0.get() { + Some(0) + } else { + self.inner.remaining() + } + } + + fn as_any(&self) -> Option<&dyn std::any::Any> { + self.inner.as_any() + } +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/hive.rs b/crates/tinyhivemind-lang/examples/hive_view/hive.rs new file mode 100644 index 00000000..8ef681fb --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/hive.rs @@ -0,0 +1,138 @@ +//! A package of several desks, described for `desks.rs` to run: one room per +//! desk under its share of the package's policy, the roster and desk set +//! core's `referral` reads, and the package's declared links. + +use tinyhivemind_core::hive::{EpisodePolicy, desk::Desk, roster::RosterMember}; +use tinyhivemind_core::referral::ReferralPolicy; +use tinyhivemind_lang::LoweredHive; + +use crate::episode::Room; + +/// A package of several desks, ready to run one floor per desk. +#[derive(Clone, Debug)] +pub(crate) struct Hive { + /// Package id, for reports. + pub(crate) package: String, + /// One room per declared desk, in declared order. + pub(crate) rooms: Vec, + /// Every seat of every desk, for the roster `referral` reads. + pub(crate) everyone: Vec, + /// The declared desks, for the desk set `referral` reads. + pub(crate) desks: Vec, + /// Declared directed links, topology first, without duplicates. + pub(crate) links: Vec<(String, String)>, + /// The package's referral policy. + pub(crate) referral: ReferralPolicy, +} + +impl Hive { + /// One floor per desk of `lowered`, each under `policy` scaled to its + /// share of the hive's seats (see [`desk_policies`]). + pub(crate) fn of(lowered: &LoweredHive, policy: &EpisodePolicy, opening: &str) -> Self { + let sizes: Vec = lowered + .desks + .iter() + .map(|desk| desk.members.len()) + .collect(); + let rooms = lowered + .desks + .iter() + .zip(desk_policies(policy, &sizes)) + .map(|(desk, own)| Room::of(lowered, desk, own, opening.to_owned())) + .collect(); + let mut everyone: Vec = Vec::new(); + for desk in &lowered.desks { + for member in &desk.members { + if !everyone.iter().any(|known| &known.id == member) { + everyone.push(RosterMember { + id: member.clone(), + name: Some(member.clone()), + }); + } + } + } + let mut links: Vec<(String, String)> = Vec::new(); + for link in lowered.topology.iter().chain(&lowered.referrals) { + let pair = (link.from.clone(), link.to.clone()); + if !links.contains(&pair) { + links.push(pair); + } + } + Self { + package: lowered.id.clone(), + rooms, + everyone, + desks: lowered.desks.clone(), + links, + referral: lowered.policies.referral, + } + } + + /// The desk whose ending is the hive's: the last declared one. + pub(crate) fn answer(&self) -> usize { + self.rooms.len().saturating_sub(1) + } + + /// Seats across every desk. + pub(crate) fn seats(&self) -> usize { + self.everyone.len() + } + + pub(crate) fn index(&self, desk: &str) -> Option { + self.rooms.iter().position(|room| room.desk.id == desk) + } + + pub(crate) fn linked(&self, from: &str, to: &str) -> bool { + self.links.iter().any(|(a, b)| a == from && b == to) + } + + /// Desks with a forward link (from an earlier-declared desk) into `desk`. + pub(crate) fn upstream(&self, desk: usize) -> Vec { + (0..desk) + .filter(|from| self.linked(&self.rooms[*from].desk.id, &self.rooms[desk].desk.id)) + .collect() + } +} + +/// Every desk's share of the package's episode policy, in `sizes` order. +/// +/// The package declares one policy for the whole hive. A desk of `size` of +/// the hive's seats takes the same share of the quorum threshold, rounded +/// up, and of the turn budget, split by largest remainder so the desks +/// together spend exactly the declared budget and never more (a budget +/// smaller than the desk count leaves some desks with none); widths are +/// capped at the desk's size. Nothing else changes. +pub(crate) fn desk_policies(policy: &EpisodePolicy, sizes: &[usize]) -> Vec { + let total = u64::try_from(sizes.iter().sum::().max(1)).unwrap_or(u64::MAX); + let weight = |size: usize| u64::try_from(size.max(1)).unwrap_or(u64::MAX); + let budget = u64::from(policy.turn_budget); + let mut turns: Vec = sizes + .iter() + .map(|size| budget * weight(*size) / total) + .collect(); + let mut by_remainder: Vec = (0..sizes.len()).collect(); + by_remainder.sort_by_key(|at| std::cmp::Reverse(budget * weight(sizes[*at]) % total)); + let leftover = budget.saturating_sub(turns.iter().sum()); + for at in by_remainder + .into_iter() + .take(usize::try_from(leftover).unwrap_or(0)) + { + turns[at] += 1; + } + sizes + .iter() + .zip(turns) + .map(|(size, turn_budget)| { + let size_u32 = u32::try_from((*size).max(1)).unwrap_or(u32::MAX); + let threshold = (u64::from(policy.quorum.threshold) * weight(*size)).div_ceil(total); + let mut desk = *policy; + desk.quorum.threshold = u32::try_from(threshold) + .unwrap_or(u32::MAX) + .clamp(1, size_u32); + desk.turn_budget = u32::try_from(turn_budget).unwrap_or(u32::MAX); + desk.round_width = policy.round_width.clamp(1, size_u32); + desk.revealed_width = policy.revealed_width.clamp(1, size_u32); + desk + }) + .collect() +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/live/README.md b/crates/tinyhivemind-lang/examples/hive_view/live/README.md new file mode 100644 index 00000000..4d23c46f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/live/README.md @@ -0,0 +1,10 @@ +# live + +`--live`: every seat turn is a real model call through OpenRouter. See the +"Live mode" section of [`../README.md`](../README.md) for the contract. + +| File | Purpose | +| --- | --- | +| `mod.rs` | `LiveSpeaker`: a `Speaker` whose rounds are concurrent model calls, metered by `--max-calls`, logged to `calls.ndjson` | +| `provider.rs` | The transport: `curl` with the key on stdin, the one request body (no tools, plugins or web search), model refusal, usage and artifacts | +| `prompt.rs` | The system and turn prompts (persona, charter, desk, grammar, the seat's projection) and the deterministic reply parser | diff --git a/crates/tinyhivemind-lang/examples/hive_view/live/mod.rs b/crates/tinyhivemind-lang/examples/hive_view/live/mod.rs new file mode 100644 index 00000000..f40f2f56 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/live/mod.rs @@ -0,0 +1,281 @@ +//! `--live`: every seat turn is a real model call through `OpenRouter`. +//! +//! [`LiveSpeaker`] is a [`Speaker`] like the simulation, so core still +//! decides every round and every ending, and `desks.rs` still routes every +//! referral; only the lines change author. A round's calls run concurrently +//! on scoped threads (the round is already bounded by `round_width`), and the +//! replies are appended in the order core authorized them, so a run is +//! reproducible up to what the model says. `--max-calls` is a hard cap: a +//! round the remaining calls cannot fill ends the run exhausted. +//! +//! Every call is logged to `hive-runs/hive-view//calls.ndjson` (seat, +//! desk, the request file, the raw reply, the recorded line), next to each +//! request body and response. The key is never written. + +pub(crate) mod prompt; +pub(crate) mod provider; + +use std::collections::BTreeMap; +use std::fs::File; +use std::io::Write; +use std::sync::Mutex; + +use serde_json::json; +use tinyhivemind_core::hive::{EpisodePolicy, HiveTurn, Phase, SessionMessage}; +use tinyhivemind_lang::LoweredHive; + +use crate::Result; +use crate::episode::Speaker; +use crate::load::{Loaded, body, roles}; +use crate::script::commit_line; +use prompt::{Brief, parse}; +use provider::{Provider, Reply}; + +/// What `--live` was asked for. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LiveConfig { + /// `OpenRouter` model id. + pub(crate) model: String, + /// Hard cap on calls for the whole run. + pub(crate) max_calls: u32, + /// `max_tokens` per call. + pub(crate) max_tokens: u32, +} + +/// Seats played by a model. +#[derive(Debug)] +pub(crate) struct LiveSpeaker { + provider: Provider, + briefs: BTreeMap, + home: BTreeMap, + max_calls: u32, + seed: u64, + log: Mutex, +} + +impl LiveSpeaker { + /// A speaker for every seat of `loaded`, writing under `run`. + /// + /// # Errors + /// + /// As [`Provider::from_env`], or when the call log cannot be created. + pub(crate) fn new(loaded: &Loaded, config: &LiveConfig, seed: u64, run: &str) -> Result { + let provider = Provider::from_env(&config.model, config.max_tokens, run)?; + let log = Mutex::new(File::create(provider.dir.join("calls.ndjson"))?); + Ok(Self { + briefs: briefs(loaded), + home: home(&loaded.lowered), + provider, + max_calls: config.max_calls, + seed, + log, + }) + } + + /// The run's artifact directory. + pub(crate) fn dir(&self) -> &std::path::Path { + &self.provider.dir + } + + /// The provider, for its usage totals. + pub(crate) fn provider(&self) -> &Provider { + &self.provider + } + + /// Whether the call cap has been reached. + pub(crate) fn capped(&self) -> bool { + self.provider.issued() >= u64::from(self.max_calls) + } + + fn brief(&self, seat: &str) -> Brief { + self.briefs.get(seat).cloned().unwrap_or_else(|| Brief { + seat: seat.to_owned(), + ..Brief::default() + }) + } + + /// One call, logged, reduced to the line the transcript records. + fn call(&self, seat: &str, kind: &str, system: &str, user: &str) -> Result { + let salt = seat.bytes().fold(self.seed, |acc, byte| { + acc.wrapping_mul(31).wrapping_add(u64::from(byte)) + }); + let (number, reply) = self.provider.complete(system, user, salt)?; + let (raw, parsed, failure) = match reply { + Reply::Text(text) => { + let parsed = parse(&text); + (text, parsed, None) + } + Reply::Failed(message) => ( + String::new(), + prompt::Parsed { + content: "note: (no reply: the call failed)".into(), + valid: false, + }, + Some(message), + ), + }; + let entry = json!({ + "call": number, + "seat": seat, + "desk": self.home.get(seat), + "kind": kind, + "request": format!("call-{number:04}.request.json"), + "reply": raw, + "recorded": parsed.content, + "valid": parsed.valid, + "failure": failure, + }); + if let Ok(mut log) = self.log.lock() { + writeln!(log, "{entry}")?; + } + Ok(parsed.content) + } + + fn turn_line( + &self, + turn: &HiveTurn, + visible: &[SessionMessage], + policy: &EpisodePolicy, + ) -> Result { + let brief = self.brief(&turn.agent_id); + let carried = if turn.phase == Phase::Commit { + commit_line(visible, policy).and_then(|line| { + line.split_whitespace() + .find_map(|word| word.strip_prefix('#').map(str::to_owned)) + }) + } else { + None + }; + let user = prompt::turn(&brief, turn, visible, &self.home, carried.as_deref()); + self.call(&turn.agent_id, "turn", &prompt::system(&brief), &user) + } +} + +impl Speaker for LiveSpeaker { + fn speak( + &mut self, + turn: &HiveTurn, + visible: &[SessionMessage], + policy: &EpisodePolicy, + ) -> String { + self.turn_line(turn, visible, policy) + .unwrap_or_else(|error| format!("note: (no reply: {error})")) + } + + fn speak_round( + &mut self, + round: &[(HiveTurn, Vec)], + policy: &EpisodePolicy, + ) -> Result> { + let this = &*self; + let results: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = round + .iter() + .map(|(turn, visible)| { + scope.spawn(move || { + this.turn_line(turn, visible, policy) + .map_err(|error| error.to_string()) + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| { + handle + .join() + .unwrap_or_else(|_| Err("a call thread panicked".into())) + }) + .collect() + }); + results + .into_iter() + .map(|result| result.map_err(Into::into)) + .collect() + } + + fn answer(&mut self, seat: &str, incoming: &str, visible: &[SessionMessage]) -> Result { + let brief = self.brief(seat); + let user = prompt::answer(&brief, incoming, visible, &self.home); + let line = self.call(seat, "answer", &prompt::system(&brief), &user)?; + // An answer goes home by core's return edge, never onward. + Ok(line + .lines() + .filter(|line| !line.contains("@#")) + .collect::>() + .join("\n")) + } + + fn as_any(&self) -> Option<&dyn std::any::Any> { + Some(self) + } + + fn remaining(&self) -> Option { + let issued = u32::try_from(self.provider.issued()).unwrap_or(u32::MAX); + Some(self.max_calls.saturating_sub(issued)) + } +} + +/// Each seat's home desk. +fn home(lowered: &LoweredHive) -> BTreeMap { + let mut home = BTreeMap::new(); + for desk in &lowered.desks { + for member in &desk.members { + home.entry(member.clone()) + .or_insert_with(|| desk.id.clone()); + } + } + home +} + +/// Persona, charter and desk for every seat. +fn briefs(loaded: &Loaded) -> BTreeMap { + let hive = &loaded.lowered; + let role_of = roles(hive); + let charter_of: BTreeMap<&str, String> = loaded + .package + .manifest + .roles + .iter() + .map(|role| { + let text = loaded + .package + .documents + .get(&role.charter) + .map(|text| body(text).trim().to_owned()) + .unwrap_or_default(); + (role.id.as_str(), text) + }) + .collect(); + let home = home(hive); + hive.seats + .iter() + .map(|seat| { + let role = role_of.get(seat.id.as_str()).copied().unwrap_or(""); + let desk = home.get(&seat.id).cloned().unwrap_or_default(); + let declared = hive.desks.iter().find(|candidate| candidate.id == desk); + let mut links: Vec = Vec::new(); + for link in hive.topology.iter().chain(&hive.referrals) { + if link.from == desk && !links.contains(&link.to) { + links.push(link.to.clone()); + } + } + let brief = Brief { + seat: seat.id.clone(), + persona: seat.prompt.clone(), + role: role.to_owned(), + charter: charter_of.get(role).cloned().unwrap_or_default(), + desk: desk.clone(), + desk_name: declared.map(|desk| desk.name.clone()).unwrap_or_default(), + desk_description: declared + .and_then(|desk| desk.description.clone()) + .unwrap_or_default(), + links: if hive.desks.len() > 1 { + links + } else { + Vec::new() + }, + }; + (seat.id.clone(), brief) + }) + .collect() +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/live/prompt.rs b/crates/tinyhivemind-lang/examples/hive_view/live/prompt.rs new file mode 100644 index 00000000..4cf215fd --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/live/prompt.rs @@ -0,0 +1,267 @@ +//! What a live seat is told, and how its reply becomes one transcript line. +//! +//! A seat's call is its persona (the seat prompt), its role charter, its +//! desk and the desks it may refer to, the trace grammar, and the problem, +//! followed by **that seat's projection** of its own desk's journal — the +//! rows `project_for` lets this turn see, so a blind round stays blind. +//! Referral rows from other desks are rows of that journal, marked with the +//! desk they came from. +//! +//! A reply is reduced deterministically to at most one trace line (the first +//! line core's own trace reader accepts, with the required fields present) +//! plus at most one `@#desk` line. A reply with no valid trace line becomes a +//! plain `note:` utterance, which core reads as no trace at all, so an +//! unparseable answer never votes. + +use std::collections::BTreeMap; + +use tinyhivemind_core::hive::{ + HiveTurn, Phase, SessionAuthor, SessionMessage, TraceKind, Visibility, trace::read, +}; + +use crate::episode::speech; + +/// Rows of a projection shown to a model, the opening always included. +const SHOWN_ROWS: usize = 80; +/// Characters kept of one kept line. +const LINE_CHARS: usize = 400; + +/// Everything about one seat a prompt needs. +#[derive(Clone, Debug, Default)] +pub(crate) struct Brief { + pub(crate) seat: String, + /// The seat prompt: the persona line and the seat's job. + pub(crate) persona: String, + pub(crate) role: String, + pub(crate) charter: String, + pub(crate) desk: String, + pub(crate) desk_name: String, + pub(crate) desk_description: String, + /// Desks this seat's desk links to, by id. + pub(crate) links: Vec, +} + +/// The trace grammar, as core reads it. +const GRAMMAR: &str = "\ +Your reply is appended to your desk's shared transcript. Write exactly ONE line \ +in this trace grammar; the line must START with the marker: + !propose # ^ put forward an answer + !support # ^ back an answer; ^ is the sequence of a message that proposed it + !object > ^ object to the message at sequence n, grounded in your own message m + !evidence ^ share a partial result (e.g. a residue) without voting + !commit # only when told the desk is recording its decision +Rules: is a bare integer with no commas, spaces or punctuation (#924, not #924. or #9,24). \ +^ cites a sequence number from the transcript; ^1 is the problem statement. Every proposal and \ +support must cite at least one ^. Only members of your desk count toward its decision."; + +/// The system prompt for one seat. +pub(crate) fn system(brief: &Brief) -> String { + let links = if brief.links.is_empty() { + "Your desk links to no other desk.".to_owned() + } else { + format!( + "Your desk may refer a question to a linked desk by adding a second line \ + `@# ` (linked: {}). Use it rarely; a desk may ask at most twice.", + brief + .links + .iter() + .map(|desk| format!("@#{desk}")) + .collect::>() + .join(", ") + ) + }; + format!( + "{persona}\n\nRole ({role}): {charter}\n\nYou sit on the {name} desk ({id}): {about}. {links}\n\n\ + You have no tools and no web or internet access. Work from the problem and the transcript alone, \ + and do the arithmetic yourself.\n\n{GRAMMAR}", + persona = brief.persona.trim(), + role = brief.role, + charter = brief.charter.trim(), + name = brief.desk_name, + id = brief.desk, + about = brief.desk_description, + ) +} + +/// The user prompt for one authorized turn. +pub(crate) fn turn( + brief: &Brief, + turn: &HiveTurn, + visible: &[SessionMessage], + home: &BTreeMap, + carried: Option<&str>, +) -> String { + let situation = match (turn.phase, turn.visibility) { + (Phase::Commit, _) => match carried { + Some(topic) => format!( + "Your desk has reached quorum on #{topic}. Record it now: `!commit #{topic} `." + ), + None => "Your desk is recording its decision; commit the answer that carried.".into(), + }, + (_, Visibility::Blind) => "This is the blind opening round: you cannot see your \ + desk-mates' answers yet. Propose your own answer (or post evidence)." + .into(), + (_, Visibility::Full) => "The desk can now see itself. Support the answer you can \ + reproduce, object to advocates of answers you can show are wrong, or propose." + .into(), + }; + format!( + "{situation}\n\nTranscript of your desk as you can see it:\n{}\n\nYou are {}. Reply with one trace line.", + transcript(visible, home, &brief.desk), + brief.seat + ) +} + +/// The user prompt for answering a referral from another desk. +pub(crate) fn answer( + brief: &Brief, + incoming: &str, + visible: &[SessionMessage], + home: &BTreeMap, +) -> String { + format!( + "Another desk referred this to you:\n{}\n\nTranscript of your desk:\n{}\n\nYou are {}. \ + Answer it with one trace line on your desk (for example `!evidence ^ ...` or \ + `!propose # ^ ...`). Do not add an @# line.", + incoming.trim(), + transcript(visible, home, &brief.desk), + brief.seat + ) +} + +/// The projection as numbered lines; rows from another desk say so. +pub(crate) fn transcript( + visible: &[SessionMessage], + home: &BTreeMap, + desk: &str, +) -> String { + let skip = visible.len().saturating_sub(SHOWN_ROWS); + visible + .iter() + .enumerate() + .filter(|(index, _)| *index == 0 || *index > skip) + .map(|(_, message)| { + let author = match &message.author { + SessionAuthor::Agent { id, .. } => match home.get(id) { + Some(from) if from != desk => format!("{id} (referral from desk {from})"), + _ => id.clone(), + }, + SessionAuthor::Operator => "problem".to_owned(), + SessionAuthor::Person { .. } | SessionAuthor::System { .. } => "host".to_owned(), + }; + format!( + "^{} {author}: {}", + message.sequence.0, + message.content.replace('\n', " / ") + ) + }) + .collect::>() + .join("\n") +} + +/// A reply reduced to the line the transcript records. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Parsed { + /// What is appended. + pub(crate) content: String, + /// Whether it carries a valid trace line. + pub(crate) valid: bool, +} + +/// Reduce a reply to one trace line, plus one `@#desk` line if it has one. +/// +/// Deterministic repairs only: markdown bullets, quotes, emphasis and code +/// fences around a line are stripped, and trailing punctuation or digit +/// separators are removed from a `#topic` (the grammar would otherwise read +/// `#924.` as a different topic from `#924`). Validity is core's own reader: +/// the first line that reads as a trace with what its kind requires +/// (`#topic` for propose, support and commit; `>target` for object). +pub(crate) fn parse(reply: &str) -> Parsed { + let lines: Vec = reply + .lines() + .map(clean) + .filter(|line| !line.is_empty() && !line.starts_with("```")) + .collect(); + let trace = lines + .iter() + .map(|line| repair(line)) + .find(|line| valid(line)); + let mention = lines + .iter() + .find(|line| !line.starts_with('!') && line.contains("@#")) + .map(|line| clip(line)); + if let Some(trace) = trace { + let mut content = clip(&trace); + if let Some(mention) = mention { + content.push('\n'); + content.push_str(&mention); + } + return Parsed { + content, + valid: true, + }; + } + let first = lines.first().map_or("(empty reply)", String::as_str); + let mut content = format!("note: {}", clip(first.trim_start_matches('!'))); + if let Some(mention) = mention.filter(|mention| mention != first) { + content.push('\n'); + content.push_str(&mention); + } + Parsed { + content, + valid: false, + } +} + +fn clean(line: &str) -> String { + let mut line = line.trim(); + loop { + let before = line; + for prefix in ["- ", "* ", "> ", "`", "\"", "**", "Reply:", "reply:"] { + line = line.strip_prefix(prefix).unwrap_or(line).trim_start(); + } + for suffix in ["`", "\"", "**"] { + line = line.strip_suffix(suffix).unwrap_or(line).trim_end(); + } + if line == before { + return line.to_owned(); + } + } +} + +/// Normalize every `#topic` word: strip emphasis, trailing punctuation and +/// digit separators. +fn repair(line: &str) -> String { + line.split(' ') + .map(|word| { + let Some(topic) = word.strip_prefix('#') else { + return word.to_owned(); + }; + let trimmed = topic + .trim_matches(|c: char| matches!(c, '*' | '_' | '`')) + .trim_end_matches(|c: char| ".,;:!?)]}'\"".contains(c)); + let digits: String = trimmed.chars().filter(|c| *c != ',' && *c != '_').collect(); + if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) { + format!("#{digits}") + } else { + format!("#{trimmed}") + } + }) + .collect::>() + .join(" ") +} + +fn valid(line: &str) -> bool { + let message = speech(1, "seat", line); + read(std::slice::from_ref(&message)) + .first() + .is_some_and(|trace| match trace.kind { + TraceKind::Propose | TraceKind::Support | TraceKind::Commit => trace.topic.is_some(), + TraceKind::Object => trace.target.is_some(), + _ => true, + }) +} + +fn clip(line: &str) -> String { + line.chars().take(LINE_CHARS).collect() +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/live/provider.rs b/crates/tinyhivemind-lang/examples/hive_view/live/provider.rs new file mode 100644 index 00000000..cbdb493d --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/live/provider.rs @@ -0,0 +1,327 @@ +//! The `OpenRouter` transport: one chat completion per call, through `curl`. +//! +//! The pattern is `examples/live_self_edit.rs`'s, tightened: the key is read +//! from `OPENROUTER_API_KEY`, checked to be safe inside a curl config line, +//! and handed to curl as a `--config -` header on **stdin**, so it never +//! appears in argv (and so in `ps`), in a request file, or in a log. Every +//! request body and response is kept under `hive-runs/hive-view//` at the +//! main checkout's root (or `$HIVE_RUNS_DIR//`). +//! +//! Web access is denied by construction: [`request_body`] builds the only +//! body this module sends, and it carries no `tools`, `tool_choice`, +//! `plugins` or `web_search_options`; [`check_model`] refuses an `:online` +//! variant or a search model before any call is made. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde_json::{Value, json}; + +use crate::Result; + +/// The one endpoint a live run calls. +pub(crate) const ENDPOINT: &str = "https://openrouter.ai/api/v1/chat/completions"; + +/// The model a live run uses when neither `--model` nor `OPENROUTER_MODEL` +/// names one: the one `live_self_edit.rs` uses, cheap and capable. +pub(crate) const DEFAULT_MODEL: &str = "openai/gpt-oss-120b:nitro"; + +/// Body keys that would give a model tools or the web. None is ever sent. +pub(crate) const FORBIDDEN_KEYS: [&str; 5] = [ + "tools", + "tool_choice", + "plugins", + "web_search_options", + "functions", +]; + +/// Refuse a model id that is malformed or would reach the web. +/// +/// The id must be `vendor/name[:variant]` in a conservative charset. An +/// `:online` variant (`OpenRouter`'s web-search suffix), and any id naming a +/// search model (`online`, `search`, `sonar`, `web`), is refused before a +/// single call is made. +/// +/// # Errors +/// +/// Fails with the reason the id is refused. +pub(crate) fn check_model(id: &str) -> Result<()> { + let charset = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/' | ':'); + if id.is_empty() || id.len() > 100 || !id.chars().all(charset) { + return Err(format!("--model {id:?} is not a model id (vendor/name[:variant])").into()); + } + if !id.starts_with(|c: char| c.is_ascii_alphanumeric()) || id.matches(':').count() > 1 { + return Err(format!("--model {id:?} is not a model id (vendor/name[:variant])").into()); + } + let lower = id.to_ascii_lowercase(); + for web in ["online", "search", "sonar", "web"] { + if lower.contains(web) { + return Err(format!( + "--model {id:?} is refused: live runs deny web access, and {web:?} names a web-search variant" + ) + .into()); + } + } + Ok(()) +} + +/// The request body for one call. Nothing else is ever sent. +pub(crate) fn request_body( + model: &str, + system: &str, + user: &str, + max_tokens: u32, + seed: u64, +) -> Value { + json!({ + "model": model, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + "max_tokens": max_tokens, + "temperature": 0.7, + "seed": seed, + // Spend reasoning tokens sparingly and keep them out of the reply. + "reasoning": {"effort": "low", "exclude": true}, + // Ask OpenRouter to report each call's cost alongside its tokens. + "usage": {"include": true}, + }) +} + +/// Tokens and cost, as `OpenRouter` reports them. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct Usage { + pub(crate) calls: u64, + pub(crate) prompt_tokens: u64, + pub(crate) completion_tokens: u64, + pub(crate) total_tokens: u64, + pub(crate) cost_usd: f64, +} + +impl Usage { + fn add(&mut self, wire: &Value) { + let field = |name: &str| wire[name].as_u64().unwrap_or(0); + self.calls += 1; + self.prompt_tokens += field("prompt_tokens"); + self.completion_tokens += field("completion_tokens"); + self.total_tokens += field("total_tokens"); + self.cost_usd += wire["cost"].as_f64().unwrap_or(0.0); + } +} + +/// One call's result. +#[derive(Clone, Debug)] +pub(crate) enum Reply { + /// The model's text (possibly empty). + Text(String), + /// A call that failed in a way the run can survive (a timeout, a 5xx, a + /// malformed body); the message is already redacted. + Failed(String), +} + +/// The live provider: key, model, and the run's artifact directory. +#[derive(Debug)] +pub(crate) struct Provider { + key: String, + pub(crate) model: String, + pub(crate) max_tokens: u32, + pub(crate) dir: PathBuf, + issued: AtomicU64, + usage: Mutex, +} + +/// The environment variable that overrides where live runs are kept. +pub(crate) const RUNS_DIR_ENV: &str = "HIVE_RUNS_DIR"; + +/// Where live run directories go: `$HIVE_RUNS_DIR` when it is set and not +/// empty, else `hive-runs/hive-view/` under the main checkout of the +/// workspace this example was built in. Not under `target/`, which `cargo +/// clean` wipes, and not inside a linked worktree, which is removed once its +/// branch lands; the root `.gitignore` keeps `/hive-runs/` out of git. +pub(crate) fn artifacts_root() -> PathBuf { + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let workspace = std::fs::canonicalize(&workspace).unwrap_or(workspace); + artifacts_root_in(&main_checkout(&workspace), std::env::var_os(RUNS_DIR_ENV)) +} + +/// [`artifacts_root`] for a given checkout root and `HIVE_RUNS_DIR` value. +pub(crate) fn artifacts_root_in(checkout: &Path, runs_dir: Option) -> PathBuf { + match runs_dir { + Some(dir) if !dir.is_empty() => PathBuf::from(dir), + _ => checkout.join("hive-runs/hive-view"), + } +} + +/// The main checkout a workspace belongs to: the workspace itself, unless its +/// `.git` is a file pointing at a linked worktree's git dir (one carrying a +/// `commondir` file), in which case the parent of the shared `.git` dir — +/// what `git rev-parse --git-common-dir` names. A submodule's `.git` file has +/// no `commondir`, so a submodule stays its own checkout. +pub(crate) fn main_checkout(workspace: &Path) -> PathBuf { + let linked = || -> Option { + let pointer = std::fs::read_to_string(workspace.join(".git")).ok()?; + let gitdir = pointer.lines().next()?.strip_prefix("gitdir:")?.trim(); + let gitdir = workspace.join(gitdir); + let common = std::fs::read_to_string(gitdir.join("commondir")).ok()?; + let common = gitdir.join(common.trim()); + let common = std::fs::canonicalize(&common).unwrap_or(common); + common.parent().map(Path::to_path_buf) + }; + linked().unwrap_or_else(|| workspace.to_path_buf()) +} + +impl Provider { + /// Read the key from the environment and create the run directory. + /// + /// # Errors + /// + /// Fails when the key is missing or unsafe to place in a curl config + /// line, when the model is refused, or the directory cannot be made. + pub(crate) fn from_env(model: &str, max_tokens: u32, run: &str) -> Result { + check_model(model)?; + let key = std::env::var("OPENROUTER_API_KEY") + .map_err(|_| "--live needs OPENROUTER_API_KEY in the environment")?; + if key.is_empty() + || key + .bytes() + .any(|b| !(0x21..=0x7e).contains(&b) || b == b'\\' || b == b'"') + { + return Err("OPENROUTER_API_KEY is not a printable token".into()); + } + let dir = artifacts_root().join(run); + std::fs::create_dir_all(&dir)?; + Ok(Self { + key, + model: model.to_owned(), + max_tokens, + dir, + issued: AtomicU64::new(0), + usage: Mutex::new(Usage::default()), + }) + } + + /// Calls issued so far. + pub(crate) fn issued(&self) -> u64 { + self.issued.load(Ordering::SeqCst) + } + + /// Usage so far. + pub(crate) fn usage(&self) -> Usage { + self.usage.lock().map(|usage| *usage).unwrap_or_default() + } + + /// Make one call. Returns the call's number and its reply. + /// + /// # Errors + /// + /// Fails — ending the run — on a refusal no retry can fix: a malformed + /// request or unknown model (HTTP 400, 404), or an authentication, + /// payment or permission refusal (401, 402, 403); or when curl cannot be + /// started. + pub(crate) fn complete(&self, system: &str, user: &str, seed: u64) -> Result<(u64, Reply)> { + let number = self.issued.fetch_add(1, Ordering::SeqCst) + 1; + let body = request_body(&self.model, system, user, self.max_tokens, seed); + if let Some(key) = FORBIDDEN_KEYS.iter().find(|key| body.get(**key).is_some()) { + return Err(format!("refusing to send a request carrying {key}").into()); + } + let request = self.dir.join(format!("call-{number:04}.request.json")); + std::fs::write(&request, serde_json::to_vec_pretty(&body)?)?; + let mut child = Command::new("curl") + .args([ + "--silent", + "--show-error", + "--max-time", + "180", + "--config", + "-", + "--header", + "Content-Type: application/json", + "--write-out", + "\n%{http_code}", + "--data-binary", + ]) + .arg(format!("@{}", request.display())) + .arg(ENDPOINT) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + { + let mut stdin = child.stdin.take().ok_or("curl stdin unavailable")?; + // The key travels on stdin only: never argv, never a file. + writeln!(stdin, "header = \"Authorization: Bearer {}\"", self.key)?; + } + let output = child.wait_with_output()?; + let stdout = + redact_user(&String::from_utf8_lossy(&output.stdout).replace(&self.key, "[redacted]")); + let (payload, status) = stdout + .rsplit_once('\n') + .map_or((stdout.as_str(), ""), |(body, code)| (body, code.trim())); + let stderr: String = String::from_utf8_lossy(&output.stderr) + .replace(&self.key, "[redacted]") + .trim() + .chars() + .take(240) + .collect(); + let status: u16 = status.parse().unwrap_or(0); + if !output.status.success() || status != 200 { + std::fs::write( + self.dir.join(format!("call-{number:04}.error.txt")), + format!("http {status}\n{payload}\n{stderr}\n"), + )?; + let message = format!( + "call {number} failed (http {status}): {}", + if payload.trim().is_empty() { + stderr.as_str() + } else { + payload.trim() + } + .chars() + .take(300) + .collect::() + ); + if matches!(status, 400..=404) { + return Err(message.into()); + } + return Ok((number, Reply::Failed(message))); + } + let wire: Value = match serde_json::from_str(payload) { + Ok(wire) => wire, + Err(error) => return Ok((number, Reply::Failed(format!("call {number}: {error}")))), + }; + std::fs::write( + self.dir.join(format!("call-{number:04}.response.json")), + serde_json::to_vec_pretty(&wire)?, + )?; + if let Ok(mut usage) = self.usage.lock() { + usage.add(&wire["usage"]); + } + if let Some(error) = wire.get("error") { + return Ok((number, Reply::Failed(format!("call {number}: {error}")))); + } + let text = wire["choices"][0]["message"]["content"] + .as_str() + .unwrap_or("") + .to_owned(); + Ok((number, Reply::Text(text))) + } +} + +/// Blank the account's `user_id` that `OpenRouter` echoes in error bodies. +pub(crate) fn redact_user(body: &str) -> String { + const FIELD: &str = "\"user_id\":\""; + let mut out = String::with_capacity(body.len()); + let mut rest = body; + while let Some(at) = rest.find(FIELD) { + let (head, tail) = rest.split_at(at + FIELD.len()); + out.push_str(head); + out.push_str("[redacted]"); + rest = tail.find('"').map_or("", |end| &tail[end..]); + } + out.push_str(rest); + out +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/load.rs b/crates/tinyhivemind-lang/examples/hive_view/load.rs new file mode 100644 index 00000000..641a4513 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/load.rs @@ -0,0 +1,158 @@ +//! Read a package directory into the document map `parse` takes. +//! +//! This is the one place the example touches the filesystem. The library stays +//! pure: it is handed strings and never opens a file. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde_json::Value; +use tinyhivemind_lang::{LoweredHive, Package, lower, parse}; + +use crate::Result; + +/// A package read from disk, parsed, lowered, and its example-only sidecars. +#[derive(Debug)] +pub(crate) struct Loaded { + /// The parsed package. + pub(crate) package: Package, + /// The lowered activation snapshot. + pub(crate) lowered: LoweredHive, + /// First paragraph of the package `README.md`, or empty. + pub(crate) summary: String, + /// `sim.json`, when the package carries one. + pub(crate) sim: Option, + /// `script.json`, when the package carries one. + pub(crate) script: Option>>, +} + +/// Read every file under `dir` into a map keyed by `/`-separated relative path. +/// +/// # Errors +/// +/// Fails when `dir` is not a directory, a file is not UTF-8, an entry is a +/// symbolic link, or `hive.json` is missing. +pub(crate) fn documents(dir: &Path) -> Result> { + if !dir.is_dir() { + return Err(format!("{} is not a package directory", dir.display()).into()); + } + let mut documents = BTreeMap::new(); + walk(dir, dir, &mut documents)?; + if !documents.contains_key("hive.json") { + return Err(format!("{} has no hive.json", dir.display()).into()); + } + Ok(documents) +} + +fn walk(root: &Path, dir: &Path, into: &mut BTreeMap) -> Result<()> { + let mut entries: Vec = std::fs::read_dir(dir)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>()?; + entries.sort(); + for path in entries { + // A link could point anywhere on disk, and a `--live` run sends the + // package's files to a model provider: refuse rather than follow it. + if std::fs::symlink_metadata(&path)?.file_type().is_symlink() { + return Err(format!( + "{} is a symbolic link; package files must be regular files", + path.display() + ) + .into()); + } + if path.is_dir() { + walk(root, &path, into)?; + continue; + } + let relative = path.strip_prefix(root)?; + let key: Vec = relative + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect(); + into.insert(key.join("/"), std::fs::read_to_string(&path)?); + } + Ok(()) +} + +/// Read, parse and lower the package in `dir`. +/// +/// # Errors +/// +/// Fails on any read, parse, validation or sidecar decode error. +pub(crate) fn load(dir: &Path) -> Result { + let documents = documents(dir)?; + let package = parse(&documents)?; + let lowered = lower(&package)?; + let summary = documents + .get("README.md") + .map(|readme| summary(readme)) + .unwrap_or_default(); + let sim = documents + .get("sim.json") + .map(|source| serde_json::from_str(source)) + .transpose()?; + let script = documents + .get("script.json") + .map(|source| serde_json::from_str(source)) + .transpose()?; + Ok(Loaded { + package, + lowered, + summary, + sim, + script, + }) +} + +/// The first prose paragraph of a README, joined onto one line. +pub(crate) fn summary(readme: &str) -> String { + let mut lines = Vec::new(); + for line in readme.lines() { + let line = line.trim(); + if line.is_empty() { + if lines.is_empty() { + continue; + } + break; + } + if line.starts_with('#') && lines.is_empty() { + continue; + } + lines.push(line); + } + lines.join(" ") +} + +/// The body of a Markdown document after optional JSON frontmatter. +pub(crate) fn body(source: &str) -> &str { + source + .strip_prefix("---\n") + .and_then(|rest| rest.split_once("\n---\n")) + .map_or(source, |(_, body)| body) +} + +/// The role each seat fills, keyed by seat id. +pub(crate) fn roles(lowered: &LoweredHive) -> BTreeMap<&str, &str> { + lowered + .candidates + .iter() + .map(|candidate| { + ( + candidate.id.as_str(), + candidate.role.as_deref().unwrap_or(""), + ) + }) + .collect() +} + +/// The desks each seat sits on, keyed by seat id, in declared desk order. +pub(crate) fn seat_desks(lowered: &LoweredHive) -> BTreeMap<&str, Vec<&str>> { + let mut map: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for desk in &lowered.desks { + for member in &desk.members { + map.entry(member.as_str()) + .or_default() + .push(desk.id.as_str()); + } + } + map +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/main.rs b/crates/tinyhivemind-lang/examples/hive_view/main.rs new file mode 100644 index 00000000..cb1a4d10 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/main.rs @@ -0,0 +1,230 @@ +//! View a hive package, and watch it run offline. +//! +//! ```sh +//! cargo run -p tinyhivemind-lang --example hive_view -- # text tree +//! cargo run -p tinyhivemind-lang --example hive_view -- --mermaid # flowchart +//! cargo run -p tinyhivemind-lang --example hive_view -- --json # structure +//! cargo run -p tinyhivemind-lang --example hive_view -- --run [--frames] [--trace out.jsonl] [--record] +//! cargo run -p tinyhivemind-lang --example hive_view -- --compare --seeds 50 [--table] +//! OPENROUTER_API_KEY=... cargo run -p tinyhivemind-lang --example hive_view -- --run --frames --live [--model ] [--max-calls N] +//! ``` +//! +//! Knobs: `--seed N`, `--round-width N`, `--turn-budget N`, `--problem `. +//! Offline, the episode is model-free: seats speak from a script, a seeded +//! default script, or the math simulation in `sim.json`. With `--live` every +//! seat turn is an `OpenRouter` call. Either way core's `hive::step` decides +//! every round and the ending, and a package of several desks runs one floor +//! per desk, linked only by referral. See `README.md` beside this file. + +mod compare; +mod desks; +mod episode; +mod frames; +mod halt; +mod hive; +mod live; +mod load; +mod math; +mod output; +mod render; +mod rng; +mod run; +mod script; +mod sim; +mod structure; + +use std::path::PathBuf; + +use crate::live::LiveConfig; +use crate::run::Knobs; + +/// The example's error type: anything, with its message. +pub(crate) type Result = std::result::Result>; + +/// Which view the command line asked for. The last mode flag wins, except +/// that `--compare` wins over `--run` (`--run --compare` is a comparison). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum Mode { + #[default] + Tree, + Mermaid, + Json, + Run, + Compare, +} + +/// What the command line asked for. +#[derive(Debug, Default)] +struct Args { + dir: Option, + mode: Mode, + output: output::RunOutput, + trace: Option, + table: bool, + seeds: u32, + packages: Vec, + knobs: Knobs, + live: bool, + model: Option, + max_calls: u32, + max_tokens: u32, +} + +impl Args { + /// The live configuration, when `--live` was given. + /// + /// # Errors + /// + /// Fails when the model id is refused (see [`live::provider::check_model`]). + fn live_config(&self, env_model: Option) -> Result> { + if !self.live { + return Ok(None); + } + let model = self + .model + .clone() + .or(env_model.filter(|model| !model.is_empty())) + .unwrap_or_else(|| live::provider::DEFAULT_MODEL.to_owned()); + live::provider::check_model(&model)?; + Ok(Some(LiveConfig { + model, + max_calls: self.max_calls, + max_tokens: self.max_tokens, + })) + } +} + +fn bounded(flag: &str, value: Option, low: u64, high: u64) -> Result { + let value = value.ok_or_else(|| format!("{flag} needs a value"))?; + let parsed: u64 = value + .parse() + .map_err(|_| format!("{flag} takes a number, not {value}"))?; + if parsed < low || parsed > high { + return Err(format!("{flag} must be in {low}..={high}").into()); + } + Ok(parsed) +} + +fn parse_args(words: impl IntoIterator) -> Result { + let mut args = Args { + seeds: 50, + max_calls: 300, + max_tokens: 1200, + knobs: Knobs { + seed: 1, + ..Knobs::default() + }, + ..Args::default() + }; + let mut words = words.into_iter(); + while let Some(arg) = words.next() { + match arg.as_str() { + "--mermaid" => args.mode = Mode::Mermaid, + "--json" => args.mode = Mode::Json, + "--run" if args.mode != Mode::Compare => args.mode = Mode::Run, + "--run" => {} + "--frames" => args.output.frames = true, + "--table" => args.table = true, + "--compare" => args.mode = Mode::Compare, + "--trace" => args.trace = Some(words.next().ok_or("--trace needs a path")?.into()), + "--seeds" => args.seeds = u32::try_from(bounded(&arg, words.next(), 1, 10_000)?)?, + "--seed" => args.knobs.seed = bounded(&arg, words.next(), 0, u64::MAX)?, + "--round-width" => { + args.knobs.round_width = Some(u32::try_from(bounded(&arg, words.next(), 1, 64)?)?); + } + "--turn-budget" => { + args.knobs.turn_budget = + Some(u32::try_from(bounded(&arg, words.next(), 1, 2000)?)?); + } + "--problem" => args.knobs.problem = Some(words.next().ok_or("--problem needs an id")?), + "--live" => args.live = true, + "--record" => args.output.record = true, + "--model" => { + let model = words.next().ok_or("--model needs an id")?; + live::provider::check_model(&model)?; + args.model = Some(model); + } + "--max-calls" => { + args.max_calls = u32::try_from(bounded(&arg, words.next(), 1, 2000)?)?; + } + "--max-tokens" => { + args.max_tokens = u32::try_from(bounded(&arg, words.next(), 64, 8192)?)?; + } + "--packages" => { + let list = words + .next() + .ok_or("--packages needs a comma-separated list")?; + args.packages = list + .split(',') + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .collect(); + } + flag if flag.starts_with("--") => return Err(format!("unknown flag {flag}").into()), + _ if args.dir.is_none() => args.dir = Some(arg.into()), + _ => return Err(format!("unexpected argument {arg}").into()), + } + } + Ok(args) +} + +fn main() { + if let Err(error) = run_main() { + eprintln!("hive_view: {error}"); + std::process::exit(1); + } +} + +fn run_main() -> Result<()> { + let args = parse_args(std::env::args().skip(1))?; + let dir = args.dir.clone().ok_or( + "usage: hive_view [--mermaid | --json | --run [--frames] | --compare]", + )?; + let live = args.live_config(std::env::var("OPENROUTER_MODEL").ok())?; + if live.is_some() && args.mode != Mode::Run { + return Err("--live drives a run: use it with --run [--frames]".into()); + } + if args.output.record && args.mode != Mode::Run { + return Err("--record keeps a run: use it with --run [--frames]".into()); + } + if args.mode == Mode::Compare { + let names = if args.packages.is_empty() { + compare::DEFAULT_PACKAGES + .iter() + .map(|name| (*name).to_owned()) + .collect() + } else { + args.packages.clone() + }; + let loaded = compare::packages(&dir, &names)?; + let stdout = std::io::stdout(); + compare::compare( + &loaded, + args.seeds, + &args.knobs, + args.table, + &mut stdout.lock(), + )?; + return Ok(()); + } + let loaded = load::load(&dir)?; + match args.mode { + Mode::Run => output::print( + &loaded, + &args.knobs, + args.output, + args.trace.as_deref(), + live.as_ref(), + )?, + Mode::Json => println!( + "{}", + serde_json::to_string(&structure::structure(&loaded)?)? + ), + Mode::Mermaid => print!("{}", render::mermaid(&loaded.lowered)), + Mode::Tree | Mode::Compare => print!("{}", render::tree(&loaded)), + } + Ok(()) +} + +#[cfg(test)] +mod test; diff --git a/crates/tinyhivemind-lang/examples/hive_view/math.rs b/crates/tinyhivemind-lang/examples/hive_view/math.rs new file mode 100644 index 00000000..e53d077a --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/math.rs @@ -0,0 +1,206 @@ +//! The problem bank: small problems with answers computed exactly here. +//! +//! "Correct" in every math run means equal to [`Problem::answer`], which is +//! arithmetic in this file rather than a value written into `sim.json`. + +use serde::Deserialize; +use serde_json::Value; + +use crate::Result; + +/// One problem as `sim.json` declares it. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +pub(crate) struct Problem { + /// Stable problem id, e.g. `lattice-6x6`. + pub(crate) id: String, + /// Which computation answers it. + pub(crate) kind: String, + /// That computation's arguments. + pub(crate) args: Vec, + /// The statement the room is posed. + pub(crate) statement: String, +} + +impl Problem { + /// The exact answer. + /// + /// # Errors + /// + /// Fails for an unknown `kind`, the wrong number of arguments, a zero + /// argument the computation cannot take, or an answer that does not fit + /// in 64 bits, or operands exceeding the simulation work limits. + pub(crate) fn answer(&self) -> Result { + match (self.kind.as_str(), self.args.as_slice()) { + ("lattice", [right, up]) => { + self.nonzero(*right, "lattice dimension")?; + self.nonzero(*up, "lattice dimension")?; + self.fit(binomial(u128::from(*right) + u128::from(*up), *right)) + } + ("modsum", [_, _, 0]) => Err(format!("problem {} has a zero modulus", self.id).into()), + ("multiples", [_, 0, _] | [_, _, 0]) => { + Err(format!("problem {} has a zero divisor", self.id).into()) + } + ("modsum", [n, power, modulus]) => { + self.nonzero(*n, "modsum length")?; + self.work(n.saturating_mul((*power).max(1)), 1_000_000)?; + self.fit(power_sum(*n, *power, *modulus)) + } + ("digitsum", [base, exponent]) => { + self.nonzero(*exponent, "digitsum exponent")?; + Ok(digit_sum(self.power(*base, *exponent)?)) + } + ("multiples", [limit, a, b]) => { + self.work(*limit, 1_000_000)?; + self.fit( + (1..*limit) + .filter(|k| k % a == 0 || k % b == 0) + .map(u128::from) + .sum(), + ) + } + ("sqdiff", [n]) => { + self.nonzero(*n, "sqdiff n")?; + self.work(*n, 10_000)?; + self.fit(square_difference(*n)) + } + ("primes", [limit]) => { + self.work(*limit, 100_000)?; + Ok(primes_below(*limit)) + } + _ => Err(format!("problem {} has an unknown kind or arity", self.id).into()), + } + } + + /// The mistake a careless solver characteristically makes on this problem. + /// + /// Wrong answers in the simulation are correlated, as real ones are: most + /// wrong seats land on the same plausible mistake rather than on noise. + /// + /// # Errors + /// + /// As [`Problem::answer`]. + pub(crate) fn classic_mistake(&self) -> Result { + let answer = self.answer()?; + let mistake = match (self.kind.as_str(), self.args.as_slice()) { + // Counting grid points instead of steps: one fewer in each direction. + ("lattice", [right, up]) => self.fit(binomial( + u128::from(*right) + u128::from(*up) - 2, + right - 1, + ))?, + // Stopping one term early. + ("modsum", [n, power, modulus]) => self.fit(power_sum(n - 1, *power, *modulus))?, + // One power too few. + ("digitsum", [base, exponent]) => digit_sum(self.power(*base, exponent - 1)?), + // Forgetting inclusion-exclusion: multiples of both counted twice. + ("multiples", [limit, a, b]) => self.fit( + (1..*limit) + .filter(|k| k % a == 0) + .chain((1..*limit).filter(|k| k % b == 0)) + .map(u128::from) + .sum(), + )?, + // Summing to n - 1. + ("sqdiff", [n]) => self.fit(square_difference(n - 1))?, + // Counting 1 as a prime. + ("primes", [limit]) => primes_below(*limit) + 1, + _ => answer + 2, + }; + // A mistake that happens to be right is not a mistake. + Ok(if mistake == answer { + answer + 2 + } else { + mistake + }) + } + + fn work(&self, steps: u64, limit: u64) -> Result<()> { + if steps > limit { + return Err(format!("problem {} exceeds the work limit ({limit})", self.id).into()); + } + Ok(()) + } + + fn nonzero(&self, value: u64, what: &str) -> Result<()> { + if value == 0 { + return Err(format!("problem {} has a zero {what}", self.id).into()); + } + Ok(()) + } + + fn fit(&self, value: u128) -> Result { + u64::try_from(value) + .map_err(|_| format!("problem {} has an answer beyond 64 bits", self.id).into()) + } + + fn power(&self, base: u64, exponent: u64) -> Result { + u32::try_from(exponent) + .ok() + .and_then(|exponent| base.checked_pow(exponent)) + .ok_or_else(|| format!("problem {} has a power beyond 64 bits", self.id).into()) + } +} + +/// Decode the problem bank from a `sim.json` value. +/// +/// # Errors +/// +/// Fails when `problems` is missing, malformed, or any answer is not +/// computable. +pub(crate) fn problems(sim: &Value) -> Result> { + let problems: Vec = serde_json::from_value( + sim.get("problems") + .cloned() + .ok_or("sim.json has no problems")?, + )?; + if problems.is_empty() { + return Err("sim.json has an empty problem bank".into()); + } + for problem in &problems { + problem.answer()?; + problem.classic_mistake()?; + } + Ok(problems) +} + +/// `C(n, k)`, exact in 128 bits (saturating if even that is exceeded, which +/// [`Problem::fit`] then refuses). +fn binomial(n: u128, k: u64) -> u128 { + let k = u128::from(k).min(n.saturating_sub(u128::from(k))); + (1..=k) + .try_fold(1_u128, |acc, i| acc.checked_mul(n - k + i).map(|v| v / i)) + .unwrap_or(u128::MAX) +} + +fn power_sum(n: u64, power: u64, modulus: u64) -> u128 { + let modulus = u128::from(modulus); + (1..=n).fold(0, |acc, k| { + let term = (0..power).fold(1, |value, _| value * u128::from(k) % modulus); + (acc + term) % modulus + }) +} + +fn digit_sum(mut value: u64) -> u64 { + let mut sum = 0; + while value > 0 { + sum += value % 10; + value /= 10; + } + sum +} + +fn square_difference(n: u64) -> u128 { + let sum: u128 = (1..=u128::from(n)).sum(); + let squares: u128 = (1..=u128::from(n)).map(|k| k * k).sum(); + sum * sum - squares +} + +fn primes_below(limit: u64) -> u64 { + (2..limit) + .filter(|candidate| { + (2..*candidate) + .take_while(|d| d * d <= *candidate) + .all(|d| candidate % d != 0) + }) + .map(|_| 1) + .sum() +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/output.rs b/crates/tinyhivemind-lang/examples/hive_view/output.rs new file mode 100644 index 00000000..8f730979 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/output.rs @@ -0,0 +1,476 @@ +//! `--run`: print one run as human lines or NDJSON frames, offline or live. + +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::Path; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use tinyhivemind_core::telemetry::{Clock, Stamped, TraceSink, Tracer}; + +use crate::Result; +use crate::desks::{DeskEnd, ReferralRecord}; +use crate::episode::{Ending, Event, Outcome, RoundRecord}; +use crate::frames::{self, Frame, ProblemFrame, UsageFrame}; +use crate::halt::{self, Halt}; +use crate::live::{LiveConfig, LiveSpeaker}; +use crate::load::Loaded; +use crate::run::{Knobs, Plan, Prepared, correct, execute, prepare, prepare_with}; + +/// Print one run: human lines, or NDJSON frames. With `live`, every seat is +/// a model call. A live run, or one with `record`, is also kept as a +/// recording in its run folder (`hive-runs/hive-view//`): the package's +/// `--json` structure as `structure.json`, written before the first frame, +/// and every frame in `frames.ndjson`, flushed as it is written, so a viewer +/// can follow the run while it is still going. Stdout is unchanged by it. +/// +/// # Errors +/// +/// Fails on a run error, a refused `--live` setup, or a trace file or +/// recording that cannot be created. +pub(crate) fn print( + loaded: &Loaded, + knobs: &Knobs, + output: RunOutput, + trace: Option<&Path>, + live: Option<&LiveConfig>, +) -> Result<()> { + let started = Instant::now(); + let as_frames = output.frames; + let run_label = run_label(&loaded.lowered.id, knobs.seed); + let mut prepared = match live { + Some(config) => prepare_with(loaded, knobs, Some((config, &run_label)))?, + None => prepare(loaded, knobs)?, + }; + let (artifacts, mut writer) = open_writer(loaded, output, live.is_some(), &run_label)?; + let truth = prepared + .problem + .as_ref() + .map(|(_, answer)| answer.to_string()); + let sink = trace + .map(|path| File::create(path).map(|file| JsonlSink(Mutex::new(BufWriter::new(file))))) + .transpose()?; + let clock = TickClock::default(); + let tracer = sink + .as_ref() + .map(|sink| Tracer::new(prepared.package().to_owned(), sink, &clock)); + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + writer.emit(&mut out, &start_frame(&prepared, knobs, live))?; + if !as_frames { + header(&mut out, &prepared, live)?; + } + let several = matches!(prepared.plan, Plan::Hive(_)); + let mut failure: Option = None; + let halt = Halt::default(); + halt::install(&mut prepared, halt.clone()); + let outcome = execute(&mut prepared, tracer.as_ref(), &mut |event| { + let written = write_event( + &mut writer, + &mut out, + event, + as_frames, + several, + truth.as_deref(), + ); + if let Err(error) = written { + // The recording is truncated: no further round is worth paying for. + halt.stop(); + failure.get_or_insert(error); + } + })?; + if let Some(error) = failure { + return Err(error.into()); + } + let correct = correct(&outcome, prepared.problem.as_ref()); + let usage = live_usage(&prepared, started, outcome.spent); + if outcome.spent { + let cap = live.map_or(0, |config| config.max_calls); + eprintln!( + "hive_view: stopped exhausted: the calls left under --max-calls {cap} cannot fill the next round" + ); + } + if let Some(usage) = &usage { + writer.emit(&mut out, &Frame::Usage(usage.clone()))?; + } + let end = Frame::End { + ending: outcome.ending.name().into(), + topic: outcome.ending.topic().map(str::to_owned), + correct, + rounds: outcome.rounds, + turns: outcome.turns, + desk: prepared.answer_desk(), + }; + writer.emit(&mut out, &end)?; + if !as_frames { + ending( + &mut out, + &outcome, + correct, + prepared.answer_desk().as_deref(), + )?; + if let Some(usage) = &usage { + human_usage(&mut out, usage)?; + } + } + if let Some(dir) = &artifacts { + let what = if live.is_some() { + "live artifacts" + } else { + "recording" + }; + eprintln!("hive_view: {what} in {}", dir.display()); + } + if let Some(sink) = &sink + && let Ok(mut writer) = sink.0.lock() + { + writer.flush()?; + } + Ok(()) +} + +/// A run folder name for package `id` at `seed`: the id encoded to one safe +/// path component (so `a/../b` cannot leave `hive-runs/hive-view`), then the +/// start time, process id and a per-process counter, so two runs starting in +/// the same millisecond, in one process or two, never share a folder. +pub(crate) fn run_label(id: &str, seed: u64) -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let safe: String = id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + // Distinct ids that encode alike (`a/b`, `a_b`) stay distinct by a hash. + let hash = id.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3) + }); + let millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_millis()); + format!( + "{safe}-{hash:08x}-seed{seed}-{millis}-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + ) +} + +/// What `--run` writes: frames or human lines on stdout, and whether an +/// offline run is also recorded (a live run always is). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct RunOutput { + /// `--frames`: NDJSON frames on stdout instead of human lines. + pub(crate) frames: bool, + /// `--record`: keep the run as a recording even offline. + pub(crate) record: bool, +} + +/// The run folder (for a live or recorded run) and the frame writer. +fn open_writer( + loaded: &Loaded, + output: RunOutput, + live: bool, + run_label: &str, +) -> Result<(Option, FrameWriter)> { + let artifacts = + (live || output.record).then(|| crate::live::provider::artifacts_root().join(run_label)); + let kept = artifacts + .as_ref() + .map(|dir| recording(dir, loaded)) + .transpose()?; + Ok(( + artifacts, + FrameWriter { + stdout: output.frames, + kept, + }, + )) +} + +/// Open a recording in `dir`: write the package's structure as +/// `structure.json` and return the `frames.ndjson` writer. +fn recording(dir: &Path, loaded: &Loaded) -> Result> { + std::fs::create_dir_all(dir)?; + let structure = serde_json::to_vec(&crate::structure::structure(loaded)?)?; + std::fs::write(dir.join("structure.json"), structure)?; + Ok(Box::new(BufWriter::new(File::create( + dir.join("frames.ndjson"), + )?))) +} + +/// Writes frames to stdout (in `--frames` mode) and to the recording, when +/// there is one; both are flushed per line, since a viewer follows a run +/// line by line. +pub(crate) struct FrameWriter { + pub(crate) stdout: bool, + pub(crate) kept: Option>, +} + +impl FrameWriter { + pub(crate) fn emit(&mut self, out: &mut dyn Write, frame: &Frame) -> std::io::Result<()> { + if !self.stdout && self.kept.is_none() { + return Ok(()); + } + let json = serde_json::to_string(frame).map_err(std::io::Error::other)?; + if self.stdout { + writeln!(out, "{json}")?; + out.flush()?; + } + if let Some(kept) = self.kept.as_mut() { + writeln!(kept, "{json}")?; + kept.flush()?; + } + Ok(()) + } +} + +/// One event as a frame (to stdout in `--frames` mode, and to a recording) +/// and, otherwise, a human line. A one-desk run reports no desk endings: its +/// `end` is its desk's. +fn write_event( + writer: &mut FrameWriter, + out: &mut dyn Write, + event: Event<'_>, + as_frames: bool, + several: bool, + truth: Option<&str>, +) -> std::io::Result<()> { + match event { + Event::Round(record) => writer.emit(out, &frames::round(record, truth))?, + Event::Referral(record) => writer.emit(out, &frames::referral(record))?, + Event::DeskEnd(end) if several => writer.emit(out, &frames::desk_end(end, truth))?, + Event::DeskEnd(_) => {} + } + if as_frames { + return Ok(()); + } + match event { + Event::Round(record) => human(out, record), + Event::Referral(record) => human_referral(out, record), + Event::DeskEnd(end) if several => human_desk_end(out, end), + Event::DeskEnd(_) => Ok(()), + } +} + +fn start_frame(prepared: &Prepared, knobs: &Knobs, live: Option<&LiveConfig>) -> Frame { + Frame::Start { + package: prepared.package().to_owned(), + seed: knobs.seed, + seats: prepared.seats(), + problem: prepared + .problem + .as_ref() + .map(|(problem, answer)| ProblemFrame { + id: problem.id.clone(), + statement: problem.statement.clone(), + answer: answer.to_string(), + }), + live: live.map(|_| true), + model: live.map(|config| config.model.clone()), + } +} + +fn human_usage(out: &mut dyn Write, usage: &UsageFrame) -> std::io::Result<()> { + writeln!( + out, + " live: {} calls, {} tokens ({} prompt, {} completion), ${:.4}, {:.1}s{}", + usage.calls, + usage.total_tokens, + usage.prompt_tokens, + usage.completion_tokens, + usage.cost_usd, + std::time::Duration::from_millis(usage.wall_ms).as_secs_f64(), + if usage.exhausted_calls { + " — stopped by --max-calls" + } else { + "" + } + ) +} + +/// The usage frame of a live run; `None` offline. +fn live_usage(prepared: &Prepared, started: Instant, spent: bool) -> Option { + let speaker = prepared.speaker.as_any()?.downcast_ref::()?; + let usage = speaker.provider().usage(); + let summary = UsageFrame { + calls: speaker.provider().issued(), + prompt_tokens: usage.prompt_tokens, + completion_tokens: usage.completion_tokens, + total_tokens: usage.total_tokens, + cost_usd: (usage.cost_usd * 1e6).round() / 1e6, + wall_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + exhausted_calls: spent || speaker.capped(), + }; + let _ = std::fs::write( + speaker.dir().join("usage.json"), + serde_json::to_vec_pretty(&summary).unwrap_or_default(), + ); + Some(summary) +} + +fn header(out: &mut dyn Write, prepared: &Prepared, live: Option<&LiveConfig>) -> Result<()> { + match &prepared.plan { + Plan::Room(room) => writeln!( + out, + "{} on desk {} — {} seats, budget {} turns, rounds of {}", + room.package, + room.desk.id, + room.members.len(), + room.policy.turn_budget, + room.policy.round_width + )?, + Plan::Hive(hive) => { + writeln!( + out, + "{} — {} seats on {} desks, one floor each, linked by referral", + hive.package, + hive.seats(), + hive.rooms.len() + )?; + for room in &hive.rooms { + writeln!( + out, + " desk {:<12} {} seats, budget {} turns, quorum {}, rounds of {}", + room.desk.id, + room.members.len(), + room.policy.turn_budget, + room.policy.quorum.threshold, + room.policy.round_width + )?; + } + } + } + if let Some(config) = live { + writeln!( + out, + "live: {} (at most {} calls, {} tokens each)", + config.model, config.max_calls, config.max_tokens + )?; + } + if let Some((problem, answer)) = &prepared.problem { + writeln!( + out, + "problem {}: {} (answer {answer})", + problem.id, problem.statement + )?; + } + writeln!(out)?; + Ok(()) +} + +fn human(out: &mut dyn Write, record: &RoundRecord) -> std::io::Result<()> { + let phase = record + .phase + .map_or_else(|| "Answer".to_owned(), |phase| format!("{phase:?}")); + writeln!( + out, + "round {} ({phase}, {:?}){}", + record.round, + record.visibility, + if record.desk_round == record.round { + String::new() + } else { + format!(" desk {} round {}", record.desk, record.desk_round) + } + )?; + for turn in &record.turns { + writeln!( + out, + " {:>14} {:<9} {:<5} saw {}/{} {}", + turn.seat, + turn.reason, + format!("{:?}", record.visibility).to_lowercase(), + turn.saw, + turn.of, + turn.content.replace('\n', " | "), + )?; + } + Ok(()) +} + +fn human_referral(out: &mut dyn Write, record: &ReferralRecord) -> std::io::Result<()> { + let verdict = if record.accepted { + format!("landed at ^{}", record.sequence.unwrap_or(0)) + } else { + format!("refused: {}", record.reason.as_deref().unwrap_or("?")) + }; + writeln!( + out, + " {} {} -> {} by {} (hop {}): {} [{}]", + record.kind, + record.from_desk, + record.to_desk, + record.seat, + record.hops, + record.content.replace('\n', " | "), + verdict + ) +} + +fn human_desk_end(out: &mut dyn Write, end: &DeskEnd) -> std::io::Result<()> { + let ending = match &end.ending { + Ending::Converged(topic) => format!("converged on #{topic}"), + other => other.name().to_owned(), + }; + writeln!( + out, + " desk {} {ending} ({} rounds, {} turns)", + end.desk, end.rounds, end.turns + ) +} + +fn ending( + out: &mut dyn Write, + outcome: &Outcome, + correct: Option, + desk: Option<&str>, +) -> std::io::Result<()> { + let verdict = match correct { + Some(true) => " (correct)", + Some(false) => " (not correct)", + None => "", + }; + let by = desk + .map(|desk| format!(" by desk {desk}")) + .unwrap_or_default(); + match &outcome.ending { + Ending::Converged(topic) => writeln!(out, "\n converged{by} on #{topic}{verdict}")?, + Ending::Deadlocked(topics) => writeln!( + out, + "\n deadlocked{by} between #{}{verdict}", + topics.join(" and #") + )?, + Ending::Exhausted | Ending::Unopened => { + writeln!(out, "\n budget exhausted{by}, no decision{verdict}")?; + } + Ending::Idle => writeln!(out, "\n nobody had anything to say{verdict}")?, + } + writeln!(out, " {} rounds, {} turns", outcome.rounds, outcome.turns) +} + +/// Writes each telemetry event as one JSON line, as `examples/lab` does. +struct JsonlSink(Mutex); + +impl TraceSink for JsonlSink { + fn record(&self, event: &Stamped) { + if let (Ok(mut out), Ok(line)) = (self.0.lock(), serde_json::to_string(event)) { + // A failed trace write must not fail the run it describes. + let _ = writeln!(out, "{line}"); + } + } +} + +/// One millisecond per reading, so traces repeat exactly. +#[derive(Debug, Default)] +struct TickClock(std::sync::atomic::AtomicU64); + +impl Clock for TickClock { + fn now_ms(&self) -> u64 { + self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + } +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/render.rs b/crates/tinyhivemind-lang/examples/hive_view/render.rs new file mode 100644 index 00000000..1f2c4563 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/render.rs @@ -0,0 +1,211 @@ +//! Human views of a lowered package: a text tree and a mermaid flowchart. + +use std::fmt::Write as _; + +use tinyhivemind_lang::{LoweredHive, LoweredSeat}; + +use crate::load::{Loaded, roles}; + +/// The text tree: roles, seats, desks, goals, missions, links and policies. +pub(crate) fn tree(loaded: &Loaded) -> String { + let hive = &loaded.lowered; + let mut out = String::new(); + let _ = writeln!(out, "{} ({})", hive.name, hive.id); + if !loaded.summary.is_empty() { + let _ = writeln!(out, " {}", loaded.summary); + } + let _ = writeln!(out, "roles"); + for role in &loaded.package.manifest.roles { + let charter = loaded + .package + .documents + .get(&role.charter) + .map(|text| first_line(crate::load::body(text))) + .unwrap_or_default(); + let _ = writeln!(out, " {}: {charter}", role.id); + } + let _ = writeln!(out, "seats"); + let role_of = roles(hive); + for seat in &hive.seats { + let _ = writeln!( + out, + " {}", + seat_line(seat, role_of.get(seat.id.as_str()).copied().unwrap_or("")) + ); + } + let _ = writeln!(out, "desks"); + for desk in &hive.desks { + let _ = writeln!( + out, + " {} \"{}\" [{:?}]: {}", + desk.id, + desk.name, + desk.responder_mode, + desk.members.join(", ") + ); + } + links(&mut out, hive); + policies(&mut out, hive); + out +} + +fn seat_line(seat: &LoweredSeat, role: &str) -> String { + let mut line = format!("{} ({role}) tools={}", seat.id, seat.tools.allow.join(",")); + if !seat.mcp.is_empty() { + let _ = write!(line, " mcp={}", seat.mcp.join(",")); + } + if let Some(memory) = &seat.memory { + let _ = write!( + line, + " memory={} [{:?}/{:?}{}] reads={} recall={:?} remember={:?}", + memory.namespace, + memory.kind, + memory.identity.lifecycle, + if memory.identity.read_only { + ", read-only" + } else { + "" + }, + memory + .settings + .reads + .iter() + .map(|reach| reach.at.as_str()) + .collect::>() + .join(","), + memory.settings.recall_at, + memory.settings.remember, + ); + } + line +} + +fn links(out: &mut String, hive: &LoweredHive) { + if !hive.goals.is_empty() { + let _ = writeln!(out, "goals"); + for goal in &hive.goals { + let children = if goal.subgoals.is_empty() { + String::new() + } else { + format!(" -> {}", goal.subgoals.join(", ")) + }; + let _ = writeln!(out, " {}: {}{children}", goal.id, goal.description); + } + } + if !hive.missions.is_empty() { + let _ = writeln!(out, "missions"); + for mission in &hive.missions { + let _ = writeln!( + out, + " {}: {} owns {}", + mission.id, + mission.role, + mission.goals.join(", ") + ); + } + } + for (name, links) in [("topology", &hive.topology), ("referrals", &hive.referrals)] { + if links.is_empty() { + continue; + } + let _ = writeln!(out, "{name}"); + for link in links { + let _ = writeln!(out, " {} -> {}", link.from, link.to); + } + } + if !hive.context.is_empty() { + let _ = writeln!(out, "context"); + for path in hive.context.keys() { + let _ = writeln!(out, " {path}"); + } + } +} + +fn policies(out: &mut String, hive: &LoweredHive) { + let episode = &hive.policies.episode; + let _ = writeln!(out, "policies"); + let _ = writeln!( + out, + " episode: budget {} turns, rounds {} blind / {} revealed, blind opening {}, quorum {} grounded={} window {}", + episode.turn_budget, + episode.round_width, + episode.revealed_width, + episode.blind_round, + episode.quorum.threshold, + episode.quorum.require_grounded, + episode.quorum.window, + ); + let referral = &hive.policies.referral; + if referral.enabled { + let _ = writeln!( + out, + " referral: max {} hops, reach {:?}, returns {}", + referral.max_hops, referral.reach, referral.returns + ); + } + let approval = &hive.policies.approval; + if approval.enabled { + let _ = writeln!( + out, + " approval: default {:?}, {} rule(s), approver {:?}", + approval.default, + approval.rules.len(), + approval.approver + ); + } + let aside = &hive.policies.aside; + if aside.enabled { + let _ = writeln!( + out, + " aside: {} members, {} messages, must surface {}", + aside.max_members, aside.max_messages, aside.must_surface + ); + } +} + +fn first_line(text: &str) -> String { + text.lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or("") + .trim() + .to_owned() +} + +/// A mermaid identifier: ASCII alphanumerics and underscores only. +pub(crate) fn node(id: &str) -> String { + id.chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect() +} + +/// A mermaid flowchart: a subgraph per desk holding its seats, solid edges +/// for topology and dashed, labelled edges for referrals. +pub(crate) fn mermaid(hive: &LoweredHive) -> String { + let mut out = String::from("flowchart LR\n"); + let role_of = roles(hive); + for desk in &hive.desks { + let _ = writeln!(out, " subgraph {}[\"{}\"]", node(&desk.id), desk.name); + for member in &desk.members { + let role = role_of.get(member.as_str()).copied().unwrap_or(""); + let _ = writeln!( + out, + " {}__{}[\"{member}
{role}\"]", + node(&desk.id), + node(member) + ); + } + let _ = writeln!(out, " end"); + } + for link in &hive.topology { + let _ = writeln!(out, " {} --> {}", node(&link.from), node(&link.to)); + } + for link in &hive.referrals { + let _ = writeln!( + out, + " {} -. refer .-> {}", + node(&link.from), + node(&link.to) + ); + } + out +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/rng.rs b/crates/tinyhivemind-lang/examples/hive_view/rng.rs new file mode 100644 index 00000000..029adc66 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/rng.rs @@ -0,0 +1,58 @@ +//! A seeded `SplitMix64` generator, so every simulated run repeats exactly. +//! +//! No `rand` dependency: the example needs reproducible draws, not quality +//! randomness, and `SplitMix64` is a dozen lines with a published reference. + +/// Parts per million, the unit every probability in `sim.json` uses. +pub(crate) const PPM: u64 = 1_000_000; + +/// `SplitMix64` (Steele, Lea and Flood, 2014). +#[derive(Clone, Debug)] +pub(crate) struct Rng(u64); + +impl Rng { + /// A generator for one stream, derived from a seed and a stream label. + /// + /// Mixing the label in means a seat's draws do not depend on how many + /// draws another seat made first. + pub(crate) fn stream(seed: u64, label: &str) -> Self { + let mut state = seed ^ 0x9E37_79B9_7F4A_7C15; + for byte in label.bytes() { + state = mix(state ^ u64::from(byte)); + } + Self(state) + } + + /// The next 64 random bits. + pub(crate) fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + mix(self.0) + } + + /// A value in `0..bound`; `0` when `bound` is zero. + pub(crate) fn below(&mut self, bound: u64) -> u64 { + if bound == 0 { + return 0; + } + self.next_u64() % bound + } + + /// A value in `low..=high` (parts per million or any integer range). + pub(crate) fn between(&mut self, low: u64, high: u64) -> u64 { + if high <= low { + return low; + } + low + self.below(high - low + 1) + } + + /// True with probability `ppm` parts per million. + pub(crate) fn chance(&mut self, ppm: u64) -> bool { + self.below(PPM) < ppm + } +} + +fn mix(mut z: u64) -> u64 { + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/run.rs b/crates/tinyhivemind-lang/examples/hive_view/run.rs new file mode 100644 index 00000000..d14131b2 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/run.rs @@ -0,0 +1,300 @@ +//! Build a plan (one room, or one floor per desk) and a speaker for a package. + +use tinyhivemind_core::hive::{ + EpisodePolicy, QuorumPolicy, desk::Desk, desk::ResponderMode, roster::RosterMember, +}; +use tinyhivemind_core::telemetry::Tracer; + +use crate::Result; +use crate::desks::run_hive; +use crate::episode::{Event, Outcome, Room, Speaker, run}; +use crate::hive::Hive; +use crate::live::LiveConfig; +use crate::live::LiveSpeaker; +use crate::load::{Loaded, roles}; +use crate::math::{Problem, problems}; +use crate::rng::Rng; +use crate::script::{Scripted, Seeded}; +use crate::sim::{MathSim, SimConfig}; + +/// Run knobs from the command line. +#[derive(Clone, Debug, Default)] +pub(crate) struct Knobs { + pub(crate) seed: u64, + pub(crate) round_width: Option, + pub(crate) turn_budget: Option, + pub(crate) problem: Option, +} + +/// What a run drives: one desk's room, or one floor per desk. +#[derive(Clone, Debug)] +pub(crate) enum Plan { + /// A package of one desk (or the single-seat baseline). + Room(Room), + /// A package of several desks, routed by referral. + Hive(Hive), +} + +/// A plan ready to run, its speaker, and the problem it poses, if math. +pub(crate) struct Prepared { + pub(crate) plan: Plan, + pub(crate) speaker: Box, + pub(crate) problem: Option<(Problem, u64)>, +} + +impl Prepared { + /// The package id. + pub(crate) fn package(&self) -> &str { + match &self.plan { + Plan::Room(room) => &room.package, + Plan::Hive(hive) => &hive.package, + } + } + + /// Seats across every desk. + pub(crate) fn seats(&self) -> usize { + match &self.plan { + Plan::Room(room) => room.members.len(), + Plan::Hive(hive) => hive.seats(), + } + } + + /// The desk whose ending is the hive's, on a run of several desks. + pub(crate) fn answer_desk(&self) -> Option { + match &self.plan { + Plan::Room(_) => None, + Plan::Hive(hive) => Some(hive.rooms[hive.answer()].desk.id.clone()), + } + } +} + +impl std::fmt::Debug for Prepared { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Prepared") + .field("plan", &self.plan) + .finish_non_exhaustive() + } +} + +/// The problem a seed poses: `--problem` if given, else a seeded pick. +/// +/// # Errors +/// +/// Fails when `--problem` names a problem the bank does not hold. +pub(crate) fn pick(bank: &[Problem], seed: u64, chosen: Option<&str>) -> Result { + if let Some(id) = chosen { + return bank + .iter() + .find(|problem| problem.id == id) + .cloned() + .ok_or_else(|| format!("no problem {id} in the bank").into()); + } + let index = usize::try_from(Rng::stream(seed, "problem").below(bank.len() as u64))?; + bank.get(index) + .cloned() + .ok_or_else(|| "empty problem bank".into()) +} + +fn apply(policy: &mut EpisodePolicy, knobs: &Knobs) { + if let Some(width) = knobs.round_width { + policy.round_width = width; + policy.revealed_width = width; + } + if let Some(budget) = knobs.turn_budget { + policy.turn_budget = budget; + } +} + +fn opening_for(loaded: &Loaded) -> String { + let hive = &loaded.lowered; + let ask = hive + .goals + .first() + .map(|goal| goal.description.clone()) + .or_else(|| hive.desks.first().and_then(|desk| desk.description.clone())) + .unwrap_or_else(|| hive.name.clone()); + format!("{ask}. Deliberate with !propose, !support and !object, citing with ^N.") +} + +fn math_opening(problem: &Problem) -> String { + format!( + "Problem {}: {} Propose your answer as # citing ^1.", + problem.id, problem.statement + ) +} + +/// The plan for a package: its one desk, or one floor per desk. Knobs apply +/// to the package's policy, which a hive then shares out among its desks. +/// +/// # Errors +/// +/// Fails on a package with no desk. +pub(crate) fn plan(loaded: &Loaded, knobs: &Knobs, opening: String) -> Result { + let lowered = &loaded.lowered; + let mut policy = lowered.policies.episode; + apply(&mut policy, knobs); + match lowered.desks.as_slice() { + [] => Err(format!("{} declares no desk", lowered.id).into()), + [only] => Ok(Plan::Room(Room::of(lowered, only, policy, opening))), + _ => Ok(Plan::Hive(Hive::of(lowered, &policy, &opening))), + } +} + +/// Every seat of a plan in desk order, with its role. +fn seat_roles(loaded: &Loaded, plan: &Plan) -> Vec<(String, String)> { + let role_of = roles(&loaded.lowered); + let members = match plan { + Plan::Room(room) => &room.members, + Plan::Hive(hive) => &hive.everyone, + }; + members + .iter() + .map(|member| { + ( + member.id.clone(), + role_of + .get(member.id.as_str()) + .copied() + .unwrap_or("") + .to_owned(), + ) + }) + .collect() +} + +/// Prepare a package's run for these knobs, offline. +/// +/// # Errors +/// +/// Fails on a malformed `sim.json`, an unknown `--problem`, or no desk. +pub(crate) fn prepare(loaded: &Loaded, knobs: &Knobs) -> Result { + prepare_with(loaded, knobs, None) +} + +/// Prepare a run, live when `live` names a model and its run label. +/// +/// A live run poses the same problem the offline one would, and lets a +/// model play every seat; without a math simulation it is posed the +/// package's opening. +/// +/// # Errors +/// +/// As [`prepare`], or as [`LiveSpeaker::new`]. +pub(crate) fn prepare_with( + loaded: &Loaded, + knobs: &Knobs, + live: Option<(&LiveConfig, &str)>, +) -> Result { + let Some(sim) = &loaded.sim else { + let plan = plan(loaded, knobs, opening_for(loaded))?; + let speaker: Box = match (live, &loaded.script) { + (Some((config, run)), _) => { + Box::new(LiveSpeaker::new(loaded, config, knobs.seed, run)?) + } + (None, Some(script)) => Box::new(Scripted::new(script)), + (None, None) => Box::new(Seeded::new(knobs.seed, seat_roles(loaded, &plan).len())), + }; + return Ok(Prepared { + plan, + speaker, + problem: None, + }); + }; + let config = SimConfig::from(sim)?; + let problem = pick(&problems(sim)?, knobs.seed, knobs.problem.as_deref())?; + let plan = plan(loaded, knobs, math_opening(&problem))?; + let simulation = MathSim::new(&config, &problem, knobs.seed, &seat_roles(loaded, &plan))?; + let answer = simulation.answer(); + let speaker: Box = match live { + Some((config, run)) => Box::new(LiveSpeaker::new(loaded, config, knobs.seed, run)?), + None => Box::new(simulation), + }; + Ok(Prepared { + plan, + speaker, + problem: Some((problem, answer)), + }) +} + +/// The single-seat baseline: one seat of the baseline role answering alone. +/// +/// Its episode still runs through core: a one-member desk, quorum of one, +/// three turns — propose, then record. +/// +/// # Errors +/// +/// As [`prepare`]. +pub(crate) fn baseline(loaded: &Loaded, knobs: &Knobs) -> Result { + let sim = loaded + .sim + .as_ref() + .ok_or("the baseline needs a math package")?; + let config = SimConfig::from(sim)?; + let problem = pick(&problems(sim)?, knobs.seed, knobs.problem.as_deref())?; + let seat = "solo".to_owned(); + let desk = Desk { + id: "solo".into(), + name: "Single seat".into(), + description: Some("One seat answering alone".into()), + members: vec![seat.clone()], + responder_mode: ResponderMode::Auto, + }; + let policy = EpisodePolicy { + turn_budget: 3, + round_width: 1, + revealed_width: 1, + quorum: QuorumPolicy { + threshold: 1, + window: 3, + require_grounded: true, + }, + ..loaded.lowered.policies.episode + }; + let mut lone = config.clone(); + lone.roles.clear(); + let simulation = MathSim::new( + &lone, + &problem, + knobs.seed, + &[(seat.clone(), config.baseline.role.clone())], + )?; + let answer = simulation.answer(); + let room = Room { + package: "single-seat".into(), + members: vec![RosterMember { + id: seat.clone(), + name: Some(seat.clone()), + }], + home: [(seat.clone(), desk.id.clone())].into(), + desk, + policy, + opening: math_opening(&problem), + }; + Ok(Prepared { + plan: Plan::Room(room), + speaker: Box::new(simulation), + problem: Some((problem, answer)), + }) +} + +/// Run a prepared plan, calling `observe` per round, referral and desk end. +/// +/// # Errors +/// +/// As [`run`] and [`run_hive`]. +pub(crate) fn execute( + prepared: &mut Prepared, + tracer: Option<&Tracer<'_>>, + observe: &mut dyn FnMut(Event<'_>), +) -> Result { + match &prepared.plan { + Plan::Room(room) => run(room, prepared.speaker.as_mut(), tracer, observe), + Plan::Hive(hive) => run_hive(hive, prepared.speaker.as_mut(), tracer, observe), + } +} + +/// Whether a math outcome decided the right answer; `None` when not math. +pub(crate) fn correct(outcome: &Outcome, problem: Option<&(Problem, u64)>) -> Option { + problem.map(|(_, answer)| outcome.ending.topic() == Some(answer.to_string().as_str())) +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/script.rs b/crates/tinyhivemind-lang/examples/hive_view/script.rs new file mode 100644 index 00000000..8793950b --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/script.rs @@ -0,0 +1,169 @@ +//! Speakers for packages without a simulation: a replayed `script.json`, or a +//! seeded default script that picks propose, support and object from the seed. + +use std::collections::{BTreeMap, VecDeque}; + +use tinyhivemind_core::hive::{ + ConsensusState, EpisodePolicy, HiveTurn, Phase, SessionMessage, TopicStanding, TraceKind, + Visibility, + quorum::{consensus, standings}, + trace::read, +}; + +use crate::episode::Speaker; +use crate::rng::Rng; + +/// What a seat with nothing left to say writes. +pub(crate) const QUIET: &str = "!question Nothing further from me."; + +/// Topics the seeded default script argues over. +const OPTIONS: [&str; 3] = ["option-a", "option-b", "option-c"]; + +/// The commit line for whatever topic carried in `visible`, if one did. +/// +/// Once the room is in [`Phase::Commit`] the authorized seat must actually +/// write `!commit` for the episode to converge; the phase alone records +/// nothing. This reads the carried topic from the seat's own projection with +/// core's folds, as `hive.rs` does. +pub(crate) fn commit_line(visible: &[SessionMessage], policy: &EpisodePolicy) -> Option { + let standing = view(visible, policy); + match consensus(&standing, &policy.quorum) { + ConsensusState::Quorum { topic } => { + Some(format!("!commit #{topic} Recording the decision.")) + } + _ => None, + } +} + +/// Standings over one projection, empty when the policy is malformed. +pub(crate) fn view(visible: &[SessionMessage], policy: &EpisodePolicy) -> Vec { + let traces = read(visible); + let at = visible + .last() + .map_or(tinyhivemind_core::hive::Sequence(0), |message| { + message.sequence + }); + standings(&traces, at, &policy.quorum).unwrap_or_default() +} + +/// The first message in `visible` that proposed or supported `topic`, by a +/// seat other than `seat`. +pub(crate) fn advocacy_of(visible: &[SessionMessage], topic: &str, seat: &str) -> Option { + read(visible) + .into_iter() + .find(|trace| { + matches!(trace.kind, TraceKind::Propose | TraceKind::Support) + && trace.topic.as_ref().is_some_and(|id| id.0 == topic) + && trace.agent_id() != Some(seat) + }) + .map(|trace| trace.sequence.0) +} + +/// Replays `script.json`: each seat's lines in order, then the default. +#[derive(Debug)] +pub(crate) struct Scripted { + lines: BTreeMap>, +} + +impl Scripted { + /// A speaker over a decoded `script.json`. + pub(crate) fn new(script: &BTreeMap>) -> Self { + Self { + lines: script + .iter() + .map(|(seat, lines)| (seat.clone(), lines.iter().cloned().collect())) + .collect(), + } + } +} + +impl Speaker for Scripted { + fn speak( + &mut self, + turn: &HiveTurn, + visible: &[SessionMessage], + policy: &EpisodePolicy, + ) -> String { + if turn.phase == Phase::Commit + && let Some(line) = commit_line(visible, policy) + { + return line; + } + self.lines + .get_mut(&turn.agent_id) + .and_then(VecDeque::pop_front) + .unwrap_or_else(|| QUIET.to_owned()) + } +} + +/// The seeded default script. +/// +/// In the blind round each seat proposes one of three options, chosen from the +/// seed. Once the room can see itself a seat supports the leading option +/// (60%), objects to a rival's advocate (25%), or adds evidence (15%). The +/// draws are per seat, so a seed fixes the whole run and different seeds end +/// differently. +#[derive(Debug)] +pub(crate) struct Seeded { + seed: u64, + streams: BTreeMap, + options: usize, +} + +impl Seeded { + /// A default script for a room of `seats`. + pub(crate) fn new(seed: u64, seats: usize) -> Self { + Self { + seed, + streams: BTreeMap::new(), + options: if seats > 4 { 3 } else { 2 }, + } + } +} + +impl Speaker for Seeded { + fn speak( + &mut self, + turn: &HiveTurn, + visible: &[SessionMessage], + policy: &EpisodePolicy, + ) -> String { + if turn.phase == Phase::Commit { + return commit_line(visible, policy).unwrap_or_else(|| QUIET.to_owned()); + } + let seed = self.seed; + let rng = self + .streams + .entry(turn.agent_id.clone()) + .or_insert_with(|| Rng::stream(seed, &turn.agent_id)); + let pick = usize::try_from(rng.below(self.options as u64)).unwrap_or(0); + let own = OPTIONS[pick % OPTIONS.len()]; + if turn.visibility == Visibility::Blind { + return format!("!propose #{own} ^1 My first read favours {own}."); + } + let standing = view(visible, policy); + let leader = standing + .iter() + .max_by_key(|standing| standing.supporters.len()) + .map(|standing| standing.topic.0.clone()); + let roll = rng.below(100); + if roll < 60 + && let Some(leader) = &leader + && let Some(cited) = advocacy_of(visible, leader, &turn.agent_id) + { + return format!("!support #{leader} ^{cited} The argument at {cited} holds up."); + } + if roll < 85 + && let Some(rival) = standing + .iter() + .find(|standing| standing.topic.0 != own && !standing.supporters.is_empty()) + && let Some(target) = advocacy_of(visible, &rival.topic.0, &turn.agent_id) + { + return format!( + "!object >{target} ^1 That case for {} skips the brief.", + rival.topic + ); + } + format!("!evidence ^1 Noting a consideration for {own}.") + } +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/sim.rs b/crates/tinyhivemind-lang/examples/hive_view/sim.rs new file mode 100644 index 00000000..ad2abad0 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/sim.rs @@ -0,0 +1,361 @@ +//! The math simulation: seeded seats with role behaviour from `sim.json`. +//! +//! Every seat derives a private answer at the start of the episode, right with +//! a probability drawn per seat from its role's range, and then acts on what it +//! can see through core's projection. Every line it writes is the trace +//! grammar core reads, so the ending is core's quorum and nothing else. +//! +//! Behaviours (`sim.json` `roles..behaviour`): +//! +//! - `guess` proposes its guess blind, then supports the leading topic once +//! (herding — it has no way to check). +//! - `solve` proposes its derivation, then objects to one advocate of a rival +//! answer per turn and supports its own answer once someone else has. +//! - `check` is `solve` with up to two objections per turn. +//! - `slice` knows the answer exactly with its role's probability; otherwise it +//! knows the answer only modulo its slice. It never claims more: it objects +//! to advocates of answers its slice rules out and supports the leading +//! answer its slice admits. +//! - `lead` proposes its derivation, then supports the leading topic once. +//! +//! In the commit phase every seat records the topic that carried in its view. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde::Deserialize; +use serde_json::Value; +use tinyhivemind_core::hive::{ + EpisodePolicy, HiveTurn, Phase, SessionMessage, TraceKind, Visibility, trace::read, +}; + +use crate::Result; +use crate::episode::Speaker; +use crate::math::Problem; +use crate::rng::{PPM, Rng}; +use crate::script::{QUIET, advocacy_of, commit_line, view}; + +/// One role's simulated behaviour. +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct RoleSim { + /// `guess`, `solve`, `check`, `slice` or `lead`. + pub(crate) behaviour: String, + /// Range, in parts per million, a seat's accuracy is drawn from. + pub(crate) accuracy: [u64; 2], +} + +/// How a wrong derivation chooses its wrong answer, in parts per million. +#[derive(Clone, Copy, Debug, Deserialize)] +pub(crate) struct Errors { + /// The problem's classic mistake. + pub(crate) classic: u64, + /// The answer plus one. + pub(crate) plus_one: u64, + /// The answer minus one. + pub(crate) minus_one: u64, +} + +/// The single seat `--compare` runs alone. +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct Baseline { + /// Its role name. + pub(crate) role: String, + /// Its behaviour and accuracy range. + #[serde(flatten)] + pub(crate) sim: RoleSim, +} + +/// Everything in `sim.json` except the problems. +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct SimConfig { + /// Behaviour per role id. + pub(crate) roles: BTreeMap, + /// Wrong-answer mix. + pub(crate) errors: Errors, + /// Moduli a partial calculator may know, by its index within its role. + pub(crate) slices: Vec, + /// The single-seat baseline. + pub(crate) baseline: Baseline, +} + +impl SimConfig { + /// Decode `sim.json`. + /// + /// # Errors + /// + /// Fails on a malformed document, an empty slice list, an unordered accuracy + /// range, or error weights that are not parts per million or are all zero. + pub(crate) fn from(sim: &Value) -> Result { + let config: Self = serde_json::from_value(sim.clone())?; + if config.slices.iter().any(|modulus| *modulus < 2) || config.slices.is_empty() { + return Err("sim.json slices must be moduli of at least two".into()); + } + let ranges = config + .roles + .values() + .chain(std::iter::once(&config.baseline.sim)) + .map(|role| role.accuracy); + for [low, high] in ranges { + if low > high || high > PPM { + return Err("sim.json accuracy ranges must be ordered parts per million".into()); + } + } + let Errors { + classic, + plus_one, + minus_one, + } = config.errors; + let weights = [classic, plus_one, minus_one]; + if weights.iter().any(|weight| *weight > PPM) || weights.iter().sum::() == 0 { + return Err( + "sim.json errors must be parts per million, at most one million each, not all zero" + .into(), + ); + } + Ok(config) + } +} + +#[derive(Clone, Debug)] +struct SeatState { + behaviour: String, + /// The full answer this seat derived, if it derived one. + own: Option, + /// `(modulus, residue)` a partial calculator knows instead. + residue: Option<(u64, u64)>, + supported: BTreeSet, + objected: BTreeSet, +} + +impl SeatState { + fn admits(&self, topic: &str) -> bool { + let Ok(value) = topic.parse::() else { + return false; + }; + match (self.own, self.residue) { + (Some(own), _) => value == own, + (None, Some((modulus, residue))) => value % modulus == residue, + (None, None) => true, + } + } +} + +/// The simulated room for one problem and one seed. +#[derive(Debug)] +pub(crate) struct MathSim { + answer: u64, + seats: BTreeMap, +} + +impl MathSim { + /// Draw every seat's accuracy and private derivation. + /// + /// `seats` is `(seat id, role id)` in desk order. + /// + /// # Errors + /// + /// Fails when a seat's role has no behaviour in `sim.json`, or the problem + /// has no computable answer. + pub(crate) fn new( + config: &SimConfig, + problem: &Problem, + seed: u64, + seats: &[(String, String)], + ) -> Result { + let answer = problem.answer()?; + let classic = problem.classic_mistake()?; + let mut states = BTreeMap::new(); + let mut index_in_role: BTreeMap<&str, usize> = BTreeMap::new(); + for (seat, role) in seats { + let sim = if role == &config.baseline.role && !config.roles.contains_key(role) { + &config.baseline.sim + } else { + config + .roles + .get(role) + .ok_or_else(|| format!("sim.json has no behaviour for role {role}"))? + }; + let index = index_in_role.entry(role.as_str()).or_default(); + let modulus = config.slices[*index % config.slices.len()]; + *index += 1; + let accuracy = Rng::stream(seed, &format!("accuracy/{seat}")) + .between(sim.accuracy[0], sim.accuracy[1]); + let mut draw = Rng::stream(seed, &format!("answer/{}/{seat}", problem.id)); + let (own, residue) = if draw.chance(accuracy) { + (Some(answer), None) + } else if sim.behaviour == "slice" { + (None, Some((modulus, answer % modulus))) + } else { + ( + Some(wrong_answer(&mut draw, &config.errors, answer, classic)), + None, + ) + }; + states.insert( + seat.clone(), + SeatState { + behaviour: sim.behaviour.clone(), + own, + residue, + supported: BTreeSet::new(), + objected: BTreeSet::new(), + }, + ); + } + Ok(Self { + answer, + seats: states, + }) + } + + /// The ground-truth answer. + pub(crate) fn answer(&self) -> u64 { + self.answer + } +} + +fn wrong_answer(rng: &mut Rng, errors: &Errors, answer: u64, classic: u64) -> u64 { + let total = errors.classic + errors.plus_one + errors.minus_one; + let roll = rng.below(total); + if roll < errors.classic { + classic + } else if roll < errors.classic + errors.plus_one || answer == 0 { + answer + 1 + } else { + answer - 1 + } +} + +impl Speaker for MathSim { + fn speak( + &mut self, + turn: &HiveTurn, + visible: &[SessionMessage], + policy: &EpisodePolicy, + ) -> String { + if turn.phase == Phase::Commit { + return commit_line(visible, policy).unwrap_or_else(|| QUIET.to_owned()); + } + let Some(seat) = self.seats.get_mut(&turn.agent_id) else { + return QUIET.to_owned(); + }; + if turn.visibility == Visibility::Blind { + return opening(seat); + } + let lines = revealed(seat, &turn.agent_id, visible, policy); + if lines.is_empty() { + QUIET.to_owned() + } else { + lines.join("\n") + } + } +} + +fn opening(seat: &SeatState) -> String { + match (seat.own, seat.residue) { + (Some(own), _) => format!("!propose #{own} ^1 My {} gives {own}.", seat.behaviour), + (None, Some((modulus, residue))) => { + format!("!evidence ^1 Partial: the answer is {residue} modulo {modulus}.") + } + (None, None) => QUIET.to_owned(), + } +} + +fn revealed( + seat: &mut SeatState, + id: &str, + visible: &[SessionMessage], + policy: &EpisodePolicy, +) -> Vec { + let standing = view(visible, policy); + let mut ranked: Vec<(&str, usize)> = standing + .iter() + .map(|standing| (standing.topic.0.as_str(), standing.supporters.len())) + .collect(); + ranked.sort_by_key(|(_, count)| std::cmp::Reverse(*count)); + let grounds = own_proposal(visible, id).unwrap_or(1); + let mut lines = Vec::new(); + let objections = match seat.behaviour.as_str() { + "solve" => 1, + "check" | "slice" => 2, + _ => 0, + }; + if objections > 0 { + for (sequence, topic) in targets(visible, &standing, id, |topic| !seat.admits(topic)) { + if lines.len() >= objections { + break; + } + if seat.objected.insert(sequence) { + lines.push(format!( + "!object >{sequence} ^{grounds} {topic} at {sequence} fails my check." + )); + } + } + } + let leader = ranked.first().map(|(topic, _)| *topic); + let wanted = match seat.behaviour.as_str() { + // A guesser cannot check anything, so it follows the room, once. + "guess" => leader.filter(|_| seat.supported.is_empty()), + // A lead follows the room as the room moves. + "lead" => leader, + // A partial calculator backs the leading answer its slice admits, once. + "slice" => ranked + .iter() + .map(|(topic, _)| *topic) + .find(|topic| seat.admits(topic)) + .filter(|_| seat.supported.is_empty()), + // A solver or checker already advocates its own answer by proposing it. + _ => None, + }; + let own = seat.own.map(|own| own.to_string()); + if let Some(topic) = wanted + && own.as_deref() != Some(topic) + && !seat.supported.contains(topic) + && let Some(cited) = advocacy_of(visible, topic, id) + { + seat.supported.insert(topic.to_owned()); + lines.push(format!("!support #{topic} ^{cited} I reproduce {topic}.")); + } + lines +} + +/// This seat's own proposal, its grounds for an objection. +fn own_proposal(visible: &[SessionMessage], id: &str) -> Option { + read(visible) + .into_iter() + .find(|trace| trace.kind == TraceKind::Propose && trace.agent_id() == Some(id)) + .map(|trace| trace.sequence.0) +} + +/// Messages advocating a rejected topic whose author is not yet silenced +/// there, most-supported topic first. +fn targets( + visible: &[SessionMessage], + standing: &[tinyhivemind_core::hive::TopicStanding], + id: &str, + rejects: impl Fn(&str) -> bool, +) -> Vec<(u64, String)> { + let mut found: Vec<(usize, u64, String)> = Vec::new(); + for trace in read(visible) { + if !matches!(trace.kind, TraceKind::Propose | TraceKind::Support) { + continue; + } + let (Some(topic), Some(author)) = (trace.topic.as_ref(), trace.agent_id()) else { + continue; + }; + if author == id || !rejects(&topic.0) { + continue; + } + let Some(standing) = standing.iter().find(|standing| standing.topic == *topic) else { + continue; + }; + if standing.silenced.iter().any(|silenced| silenced == author) { + continue; + } + found.push((standing.supporters.len(), trace.sequence.0, topic.0.clone())); + } + found.sort_by_key(|(support, sequence, _)| (std::cmp::Reverse(*support), *sequence)); + found + .into_iter() + .map(|(_, sequence, topic)| (sequence, topic)) + .collect() +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/structure.rs b/crates/tinyhivemind-lang/examples/hive_view/structure.rs new file mode 100644 index 00000000..0e1863d1 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/structure.rs @@ -0,0 +1,194 @@ +//! The `--json` structure: one object describing a lowered package. + +use serde::Serialize; +use serde_json::{Value, json}; +use tinyhivemind_lang::Link; + +use crate::Result; +use crate::hive::desk_policies; +use crate::load::{Loaded, body, roles, seat_desks}; +use crate::math::problems; + +#[derive(Debug, Serialize)] +struct RoleJson { + id: String, + charter: String, +} + +#[derive(Debug, Serialize)] +struct SeatJson { + id: String, + label: String, + role: String, + desks: Vec, + /// Bound memory namespace, when the seat has one (an addition). + memory: Option, +} + +#[derive(Debug, Serialize)] +struct DeskJson { + id: String, + name: String, + description: Option, + members: Vec, + responder_mode: String, + /// Addition: the policy this desk's own floor runs under — the package's + /// for one desk, its share of it for several. + episode: DeskEpisodeJson, +} + +#[derive(Debug, Serialize)] +struct DeskEpisodeJson { + turn_budget: u32, + round_width: u32, + revealed_width: u32, + quorum_threshold: u32, +} + +#[derive(Debug, Serialize)] +struct GoalJson { + id: String, + description: String, + subgoals: Vec, +} + +#[derive(Debug, Serialize)] +struct EpisodeJson { + turn_budget: u32, + round_width: u32, + revealed_width: u32, + blind_round: bool, + quorum_threshold: u32, + /// Additions: the rest of the quorum policy. + quorum_window: u32, + require_grounded: bool, +} + +#[derive(Debug, Serialize)] +struct StructureJson { + id: String, + name: String, + summary: String, + roles: Vec, + seats: Vec, + desks: Vec, + topology: Vec, + referrals: Vec, + goals: Vec, + episode: EpisodeJson, + sim: Value, + /// Addition: missions as `{id, role, goals}`. + missions: Vec, +} + +/// The structure object for a loaded package. +/// +/// # Errors +/// +/// Fails when a math package's problem bank does not decode. +pub(crate) fn structure(loaded: &Loaded) -> Result { + let hive = &loaded.lowered; + let role_of = roles(hive); + let desks_of = seat_desks(hive); + let episode = &hive.policies.episode; + let sizes: Vec = hive.desks.iter().map(|desk| desk.members.len()).collect(); + let structure = StructureJson { + id: hive.id.clone(), + name: hive.name.clone(), + summary: loaded.summary.clone(), + roles: loaded + .package + .manifest + .roles + .iter() + .map(|role| RoleJson { + id: role.id.clone(), + charter: loaded + .package + .documents + .get(&role.charter) + .and_then(|text| body(text).lines().find(|line| !line.trim().is_empty())) + .unwrap_or("") + .trim() + .to_owned(), + }) + .collect(), + seats: hive + .seats + .iter() + .map(|seat| SeatJson { + id: seat.id.clone(), + label: seat.label.clone(), + role: role_of + .get(seat.id.as_str()) + .copied() + .unwrap_or("") + .to_owned(), + desks: desks_of + .get(seat.id.as_str()) + .map(|desks| desks.iter().map(|desk| (*desk).to_owned()).collect()) + .unwrap_or_default(), + memory: seat.memory.as_ref().map(|memory| memory.namespace.clone()), + }) + .collect(), + desks: hive + .desks + .iter() + .zip(desk_policies(episode, &sizes)) + .map(|(desk, split)| DeskJson { + id: desk.id.clone(), + name: desk.name.clone(), + description: desk.description.clone(), + members: desk.members.clone(), + responder_mode: format!("{:?}", desk.responder_mode).to_lowercase(), + episode: { + let own = if hive.desks.len() > 1 { + split + } else { + *episode + }; + DeskEpisodeJson { + turn_budget: own.turn_budget, + round_width: own.round_width, + revealed_width: own.revealed_width, + quorum_threshold: own.quorum.threshold, + } + }, + }) + .collect(), + topology: hive.topology.clone(), + referrals: hive.referrals.clone(), + goals: hive + .goals + .iter() + .map(|goal| GoalJson { + id: goal.id.clone(), + description: goal.description.clone(), + subgoals: goal.subgoals.clone(), + }) + .collect(), + episode: EpisodeJson { + turn_budget: episode.turn_budget, + round_width: episode.round_width, + revealed_width: episode.revealed_width, + blind_round: episode.blind_round, + quorum_threshold: episode.quorum.threshold, + quorum_window: episode.quorum.window, + require_grounded: episode.quorum.require_grounded, + }, + sim: sim(loaded)?, + missions: hive.missions.clone(), + }; + Ok(serde_json::to_value(structure)?) +} + +fn sim(loaded: &Loaded) -> Result { + let Some(sim) = &loaded.sim else { + return Ok(json!({"kind": "script", "scripted": loaded.script.is_some()})); + }; + let problems: Vec = problems(sim)? + .into_iter() + .map(|problem| json!({"id": problem.id, "statement": problem.statement})) + .collect(); + Ok(json!({"kind": "math", "problems": problems})) +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/test/README.md b/crates/tinyhivemind-lang/examples/hive_view/test/README.md new file mode 100644 index 00000000..12f0a4c6 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/test/README.md @@ -0,0 +1,17 @@ +# test + +Offline tests for `hive_view`, one module per behaviour. No network, no +clock, no temporary files. + +| File | Covers | +| --- | --- | +| `mod.rs` | Module wiring | +| `support.rs` | Shared fixtures: gallery paths, knobs, an ending at a seed | +| `packages.rs` | Loading, summaries, the mermaid view, the problem bank | +| `runs.rs` | Scripted, seeded and simulated episodes; budgets; determinism; `sim.json` error weights | +| `routing.rs` | One floor per desk, links, hop limits, disabled policy, asks, the width cap | +| `record.rs` | Recording flush, run labels, and the halt that stops a run on a failed write | +| `wire.rs` | The pinned JSON of every frame | +| `compare.rs` | `--compare` stream and refusals | +| `args.rs` | Knob bounds and the `--live` flags | +| `live.rs` | The request body (no tools/plugins/web), model refusal, prompts, reply parsing | diff --git a/crates/tinyhivemind-lang/examples/hive_view/test/args.rs b/crates/tinyhivemind-lang/examples/hive_view/test/args.rs new file mode 100644 index 00000000..0eb61c3b --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/test/args.rs @@ -0,0 +1,74 @@ +//! Command-line parsing: knob bounds and the `--live` flags. + +#[test] +fn knobs_outside_their_bounds_are_rejected() { + let parse = |argv: &[&str]| crate::parse_args(argv.iter().map(|arg| (*arg).to_owned())); + assert!(parse(&["dir", "--round-width", "0"]).is_err()); + assert!(parse(&["dir", "--turn-budget", "2001"]).is_err()); + assert!(parse(&["dir", "--seed", "x"]).is_err()); + assert!(parse(&["dir", "--bogus"]).is_err()); + assert!( + parse(&["dir", "--round-width", "8", "--seed", "4"]) + .is_ok_and(|args| args.knobs.seed == 4 && args.knobs.round_width == Some(8)) + ); +} + +#[test] +fn live_flags_parse_with_bounds_and_refuse_a_web_model() { + let parse = |argv: &[&str]| crate::parse_args(argv.iter().map(|arg| (*arg).to_owned())); + let args = parse(&[ + "dir", + "--run", + "--live", + "--max-calls", + "40", + "--max-tokens", + "512", + ]); + assert!(args.is_ok_and(|args| args.live && args.max_calls == 40 && args.max_tokens == 512)); + assert!(parse(&["dir", "--live", "--max-calls", "0"]).is_err()); + assert!(parse(&["dir", "--live", "--max-calls", "2001"]).is_err()); + assert!(parse(&["dir", "--live", "--max-tokens", "10"]).is_err()); + assert!(parse(&["dir", "--live", "--model", "openai/gpt-4o:online"]).is_err()); + assert!(parse(&["dir", "--live", "--model", "a b"]).is_err()); +} + +#[test] +fn the_live_model_comes_from_the_flag_then_the_environment_then_the_default() -> crate::Result<()> { + let parse = |argv: &[&str]| crate::parse_args(argv.iter().map(|arg| (*arg).to_owned())); + let offline = parse(&["dir", "--run"])?; + assert_eq!(offline.live_config(Some("x/y".into()))?, None); + let live = parse(&["dir", "--run", "--live"])?; + let model = |env: Option<&str>| -> crate::Result { + Ok(live + .live_config(env.map(str::to_owned))? + .ok_or("live")? + .model) + }; + assert_eq!(model(None)?, crate::live::provider::DEFAULT_MODEL); + assert_eq!(model(Some(""))?, crate::live::provider::DEFAULT_MODEL); + assert_eq!( + model(Some("google/gemini-2.0-flash-001"))?, + "google/gemini-2.0-flash-001" + ); + // A web variant from the environment is refused before any call. + assert!( + live.live_config(Some("openai/gpt-4o-mini:online".into())) + .is_err() + ); + let flagged = parse(&[ + "dir", + "--run", + "--live", + "--model", + "meta-llama/llama-3.3-70b-instruct", + ])?; + assert_eq!( + flagged + .live_config(Some("x/y".into()))? + .ok_or("live")? + .model, + "meta-llama/llama-3.3-70b-instruct" + ); + Ok(()) +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/test/compare.rs b/crates/tinyhivemind-lang/examples/hive_view/test/compare.rs new file mode 100644 index 00000000..c67511e0 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/test/compare.rs @@ -0,0 +1,82 @@ +//! `--compare`: its stream, its summary, and what it refuses. + +use serde_json::Value; + +use crate::Result; +use crate::compare::{BASELINE, compare, packages}; +use crate::run::Knobs; + +use super::support::gallery; + +#[test] +fn compare_streams_one_line_per_seed_then_a_summary_with_the_baseline_first() -> Result<()> { + let dir = gallery("math-swarm"); + let loaded = packages(&dir, &["math-panel".into(), "math-swarm".into()])?; + let mut out = Vec::new(); + let rows = compare(&loaded, 3, &Knobs::default(), false, &mut out)?; + let lines: Vec = String::from_utf8(out)? + .lines() + .map(serde_json::from_str) + .collect::>()?; + assert_eq!(lines.len(), 4); + for (index, line) in lines[..3].iter().enumerate() { + assert_eq!(line["type"], "seed"); + assert_eq!(line["seed"], index + 1); + let packages: Vec<&str> = line["results"] + .as_array() + .ok_or("results")? + .iter() + .filter_map(|result| result["package"].as_str()) + .collect(); + assert_eq!(packages, [BASELINE, "math-panel", "math-swarm"]); + } + assert_eq!(lines[3]["type"], "summary"); + assert_eq!(lines[3]["seeds"], 3); + let seats: Vec = rows.iter().map(|row| row.seats).collect(); + assert_eq!(seats, [1, 5, 50]); + for row in &rows { + for rate in [ + row.accuracy, + row.converged, + row.deadlocked, + row.exhausted, + row.idle, + ] { + assert!((0.0..=1.0).contains(&rate)); + } + let endings = row.converged + row.deadlocked + row.exhausted + row.idle; + assert!((endings - 1.0).abs() < 0.01, "{row:?}"); + } + Ok(()) +} + +#[test] +fn compare_refuses_a_package_without_a_simulation() { + let error = packages(&gallery("math-swarm"), &["review-board".into()]) + .err() + .map(|error| error.to_string()); + assert!(error.is_some_and(|error| error.contains("has no sim.json"))); +} + +#[test] +fn compare_refuses_arms_that_define_the_same_problem_id_differently() -> Result<()> { + let dir = gallery("math-swarm"); + let first = packages(&dir, &["math-panel".into()])?; + let mut second = packages(&dir, &["math-swarm".into()])?; + let sim = second[0].sim.as_mut().ok_or("sim")?; + let bank = sim["problems"].as_array_mut().ok_or("bank")?; + for problem in bank { + problem["statement"] = Value::from("a different question under the same id"); + } + let mut loaded = first; + loaded.append(&mut second); + let knobs = Knobs::default(); + let error = compare(&loaded, 2, &knobs, false, &mut Vec::new()) + .err() + .map(|error| error.to_string()); + assert!( + error.is_some_and(|error| error.contains("differently")), + "arms must share the same problem" + ); + Ok(()) +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/test/live.rs b/crates/tinyhivemind-lang/examples/hive_view/test/live.rs new file mode 100644 index 00000000..ecb4270d --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/test/live.rs @@ -0,0 +1,268 @@ +//! The live host offline: the request body it would send, the models it +//! refuses, the prompt a seat sees, and how a reply becomes a line. Nothing +//! here makes a call. + +use std::collections::BTreeMap; + +use serde_json::json; +use tinyhivemind_core::hive::{ + BidReason, HiveTurn, Phase, Sequence, SessionAuthor, Visibility, project_for, +}; + +use crate::episode::{message, speech}; +use crate::live::prompt::{self, Brief, Parsed, parse}; +use crate::live::provider::{ + DEFAULT_MODEL, FORBIDDEN_KEYS, artifacts_root_in, check_model, main_checkout, redact_user, + request_body, +}; + +#[test] +fn the_request_body_pins_its_form_and_carries_no_tools_plugins_or_web_search() { + let body = request_body("openai/gpt-oss-120b:nitro", "sys", "user", 600, 42); + assert_eq!( + body, + json!({ + "model": "openai/gpt-oss-120b:nitro", + "messages": [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "user"}, + ], + "max_tokens": 600, + "temperature": 0.7, + "seed": 42, + "reasoning": {"effort": "low", "exclude": true}, + "usage": {"include": true}, + }) + ); + for key in FORBIDDEN_KEYS { + assert!(body.get(key).is_none(), "{key} must never be sent"); + } + for key in ["tools", "plugins", "web_search_options"] { + assert!(FORBIDDEN_KEYS.contains(&key)); + } +} + +#[test] +fn a_web_capable_or_malformed_model_is_refused_before_any_call() { + for refused in [ + "openai/gpt-4o-mini:online", + "openai/gpt-4o-mini:ONLINE", + "perplexity/sonar", + "openai/gpt-4o-search-preview", + "vendor/model-web", + "", + "a b", + "vendor/model:x:y", + "-vendor/model", + "vendor/model;rm", + ] { + assert!( + check_model(refused).is_err(), + "{refused:?} should be refused" + ); + } + for accepted in [ + DEFAULT_MODEL, + "google/gemini-2.0-flash-001", + "meta-llama/llama-3.3-70b-instruct:free", + ] { + assert!( + check_model(accepted).is_ok(), + "{accepted:?} should be accepted" + ); + } +} + +#[test] +fn a_reply_reduces_to_its_first_valid_trace_line() { + let parsed = + parse("Let me think.\n\n- **!propose #1,287. ^1 Choose 5 of 13.**\n!support #9 ^2"); + assert_eq!( + parsed, + Parsed { + content: "!propose #1287 ^1 Choose 5 of 13.".into(), + valid: true, + } + ); + // A trace line missing what its kind needs is skipped for the next one. + let parsed = parse("!propose ^1 no topic\n!object >4 ^3 4 is wrong"); + assert_eq!(parsed.content, "!object >4 ^3 4 is wrong"); + // A referral line rides along. + let parsed = parse("```\n!evidence ^1 residue 2 mod 3\n```\n@#verify please check"); + assert_eq!( + parsed.content, + "!evidence ^1 residue 2 mod 3\n@#verify please check" + ); + assert!(parsed.valid); +} + +#[test] +fn a_reply_without_a_valid_trace_line_is_recorded_as_a_note_that_cannot_vote() { + let parsed = parse("I believe the answer is 924 because of symmetry."); + assert!(!parsed.valid); + assert_eq!( + parsed.content, + "note: I believe the answer is 924 because of symmetry." + ); + let parsed = parse("!vote #924"); + assert!(!parsed.valid); + assert!(parsed.content.starts_with("note: vote")); + let row = speech(3, "seat", &parsed.content); + assert_eq!( + (tinyhivemind_core::hive::trace::read(std::slice::from_ref(&row))).len(), + 0 + ); + assert_eq!(parse("").content, "note: (empty reply)"); +} + +fn brief() -> Brief { + Brief { + seat: "verify-01".into(), + persona: "You are a \"skeptical auditor\".\nYou trust nothing.".into(), + role: "verifier".into(), + charter: "Check every proposal.".into(), + desk: "verify".into(), + desk_name: "Verify".into(), + desk_description: "Independent checkers".into(), + links: vec!["synthesis".into()], + } +} + +#[test] +fn the_system_prompt_carries_persona_charter_links_grammar_and_no_web() { + let system = prompt::system(&brief()); + for needle in [ + "You are a \"skeptical auditor\".", + "Role (verifier): Check every proposal.", + "Verify desk (verify)", + "@#synthesis", + "no web or internet access", + "!propose # ^", + "!object > ^", + "!commit #", + ] { + assert!(system.contains(needle), "missing {needle:?} in\n{system}"); + } +} + +#[test] +fn a_blind_turn_prompt_shows_only_what_core_projects() { + let journal = vec![ + message(1, SessionAuthor::Operator, "Problem p: 2+2?"), + speech(2, "compute-01", "!propose #4 ^1 from compute"), + speech(3, "verify-02", "!propose #5 ^1 a peer this episode"), + ]; + let turn = HiveTurn { + agent_id: "verify-01".into(), + phase: Phase::Deliberate, + visibility: Visibility::Blind, + reason: BidReason::Salience, + watermark: Sequence(2), + round_start: Sequence(3), + }; + let visible = project_for(&turn, &journal); + let home = BTreeMap::from([ + ("compute-01".to_owned(), "compute".to_owned()), + ("verify-02".to_owned(), "verify".to_owned()), + ]); + let text = prompt::turn(&brief(), &turn, &visible, &home, None); + assert!(text.contains("^1 problem: Problem p: 2+2?")); + assert!(text.contains("^2 compute-01 (referral from desk compute): !propose #4")); + assert!( + !text.contains("a peer this episode"), + "blind rounds stay blind:\n{text}" + ); + assert!(text.contains("blind opening round")); + let commit = HiveTurn { + phase: Phase::Commit, + visibility: Visibility::Full, + ..turn + }; + let text = prompt::turn(&brief(), &commit, &visible, &home, Some("4")); + assert!(text.contains("`!commit #4 `")); +} + +#[test] +fn an_error_body_has_its_account_id_redacted() { + let body = r#"{"error":{"message":"bad","code":400},"user_id":"user_abc123"}"#; + assert_eq!( + redact_user(body), + r#"{"error":{"message":"bad","code":400},"user_id":"[redacted]"}"# + ); + assert_eq!(redact_user("plain"), "plain"); +} + +#[test] +fn live_artifacts_live_outside_target_unless_hive_runs_dir_says_otherwise() { + let workspace = std::path::Path::new("/repo"); + let default = artifacts_root_in(workspace, None); + assert_eq!(default, workspace.join("hive-runs/hive-view")); + assert!(!default.starts_with(workspace.join("target"))); + assert_eq!( + artifacts_root_in(workspace, Some("".into())), + workspace.join("hive-runs/hive-view") + ); + assert_eq!( + artifacts_root_in(workspace, Some("/elsewhere/runs".into())), + std::path::PathBuf::from("/elsewhere/runs") + ); +} + +/// A scratch directory under `target/`, removed when dropped. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(name: &str) -> crate::Result { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/hive-view-test") + .join(format!("{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir)?; + Ok(Self(std::fs::canonicalize(&dir)?)) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn a_linked_worktree_keeps_live_artifacts_in_its_main_checkout() -> crate::Result<()> { + let scratch = Scratch::new("worktree")?; + let main = scratch.0.join("main"); + let gitdir = main.join(".git/worktrees/feature"); + std::fs::create_dir_all(&gitdir)?; + std::fs::write(gitdir.join("commondir"), "../..\n")?; + let worktree = main.join("worktrees/feature"); + std::fs::create_dir_all(&worktree)?; + std::fs::write( + worktree.join(".git"), + format!("gitdir: {}\n", gitdir.display()), + )?; + + assert_eq!(main_checkout(&worktree), main); + assert_eq!(main_checkout(&main), main, "a main checkout is its own"); + assert_eq!( + artifacts_root_in(&main_checkout(&worktree), None), + main.join("hive-runs/hive-view") + ); + Ok(()) +} + +#[test] +fn a_submodule_or_a_bare_directory_is_its_own_checkout() -> crate::Result<()> { + let scratch = Scratch::new("submodule")?; + let parent = scratch.0.join("super"); + std::fs::create_dir_all(parent.join(".git/modules/child"))?; + let child = parent.join("child"); + std::fs::create_dir_all(&child)?; + std::fs::write(child.join(".git"), "gitdir: ../.git/modules/child\n")?; + assert_eq!(main_checkout(&child), child, "no commondir: not a worktree"); + + let plain = scratch.0.join("plain"); + std::fs::create_dir_all(&plain)?; + assert_eq!(main_checkout(&plain), plain, "no .git at all"); + Ok(()) +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/test/mod.rs b/crates/tinyhivemind-lang/examples/hive_view/test/mod.rs new file mode 100644 index 00000000..e36e090f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/test/mod.rs @@ -0,0 +1,14 @@ +//! Offline tests for `hive_view`: loading, rendering, runs, desk routing, the +//! frame wire form, the comparison, and the live host's request and reply +//! handling. No network and no clock; the symlink refusal alone writes a scratch +//! directory, removed afterwards. + +mod args; +mod compare; +mod live; +mod packages; +mod record; +mod routing; +mod runs; +mod support; +mod wire; diff --git a/crates/tinyhivemind-lang/examples/hive_view/test/packages.rs b/crates/tinyhivemind-lang/examples/hive_view/test/packages.rs new file mode 100644 index 00000000..ad28ee3e --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/test/packages.rs @@ -0,0 +1,199 @@ +//! Loading, summaries, rendering and the problem bank. + +use crate::Result; +use crate::load::{documents, load, summary}; +use crate::math::problems; +use crate::render::{mermaid, node}; + +use super::support::gallery; + +#[test] +fn the_loader_rejects_a_directory_without_hive_json() { + let error = documents(&gallery("review-board").join("roles")) + .err() + .map(|error| error.to_string()); + assert!(error.is_some_and(|error| error.contains("has no hive.json"))); +} + +#[test] +fn the_loader_rejects_a_path_that_is_not_a_directory() { + let error = documents(&gallery("review-board").join("hive.json")) + .err() + .map(|error| error.to_string()); + assert!(error.is_some_and(|error| error.contains("is not a package directory"))); +} + +#[test] +fn every_gallery_package_loads_and_keeps_its_sidecars_out_of_the_package() -> Result<()> { + for name in [ + "review-board", + "research-debate", + "incident-response", + "memory-guild", + "self-improving", + "math-panel", + "math-swarm", + ] { + let loaded = load(&gallery(name))?; + assert_eq!(loaded.lowered.id, name); + assert!(!loaded.summary.is_empty(), "{name} needs a README summary"); + assert!(!loaded.package.documents.contains_key("sim.json")); + assert!(!loaded.package.documents.contains_key("script.json")); + } + Ok(()) +} + +#[test] +fn a_summary_is_the_first_paragraph_after_the_heading() { + assert_eq!( + summary("# title\n\nOne line\nand two.\n\nNot this."), + "One line and two." + ); + assert_eq!(summary(""), ""); +} + +#[test] +fn the_mermaid_view_has_an_edge_for_every_topology_link() -> Result<()> { + let loaded = load(&gallery("incident-response"))?; + let chart = mermaid(&loaded.lowered); + assert!(chart.starts_with("flowchart LR\n")); + assert_ne!((loaded.lowered.topology).len(), 0); + for link in &loaded.lowered.topology { + let edge = format!(" {} --> {}\n", node(&link.from), node(&link.to)); + assert!(chart.contains(&edge), "missing {edge:?} in\n{chart}"); + } + for link in &loaded.lowered.referrals { + let edge = format!(" {} -. refer .-> {}\n", node(&link.from), node(&link.to)); + assert!(chart.contains(&edge), "missing {edge:?}"); + } + for desk in &loaded.lowered.desks { + assert!(chart.contains(&format!("subgraph {}[", node(&desk.id)))); + } + Ok(()) +} + +#[test] +fn the_problem_bank_answers_are_computed_exactly() -> Result<()> { + let loaded = load(&gallery("math-swarm"))?; + let bank = problems(loaded.sim.as_ref().ok_or("math-swarm has a sim.json")?)?; + let expected = [ + ("lattice-6x6", 924), + ("lattice-5x8", 1287), + ("modsum-squares", 14), + ("modsum-cubes", 30), + ("digitsum-2pow50", 76), + ("multiples-3-5", 233_168), + ("sqdiff-20", 41_230), + ("primes-200", 46), + ]; + for (id, answer) in expected { + let problem = bank.iter().find(|problem| problem.id == id).ok_or(id)?; + assert_eq!(problem.answer()?, answer, "{id}"); + assert_ne!(problem.classic_mistake()?, answer, "{id}"); + } + Ok(()) +} + +#[test] +fn the_structure_reports_the_policy_each_desk_runs_under() -> Result<()> { + let swarm = crate::structure::structure(&load(&gallery("math-swarm"))?)?; + let desks = swarm["desks"].as_array().ok_or("desks")?; + assert_eq!(desks.len(), 5); + for desk in desks { + assert_eq!( + desk["episode"], + serde_json::json!({"turn_budget":30,"round_width":10,"revealed_width":10,"quorum_threshold":6}) + ); + } + let panel = crate::structure::structure(&load(&gallery("math-panel"))?)?; + assert_eq!(panel["desks"][0]["episode"]["quorum_threshold"], 3); + assert_eq!(panel["desks"][0]["episode"]["turn_budget"], 15); + Ok(()) +} + +#[test] +fn the_problem_bank_rejects_zero_divisors_instead_of_panicking() { + for (kind, args) in [ + ("multiples", serde_json::json!([10, 0, 5])), + ("multiples", serde_json::json!([10, 3, 0])), + ("modsum", serde_json::json!([4, 2, 0])), + ] { + let sim = serde_json::json!({"problems": [ + {"id": "bad", "kind": kind, "args": args, "statement": "s"} + ]}); + let error = problems(&sim).err().map(|error| error.to_string()); + assert!(error.is_some_and(|error| error.contains("zero")), "{kind}"); + } +} + +#[test] +fn the_problem_bank_rejects_zero_and_overflowing_arguments_instead_of_panicking() { + for (kind, args) in [ + ("lattice", serde_json::json!([0, 5])), + ("lattice", serde_json::json!([5, 0])), + ("lattice", serde_json::json!([100, 100])), + ("modsum", serde_json::json!([0, 2, 7])), + ("digitsum", serde_json::json!([2, 0])), + ("digitsum", serde_json::json!([2, 64])), + ("digitsum", serde_json::json!([10, 4_294_967_296_u64])), + ("sqdiff", serde_json::json!([0])), + ] { + let sim = serde_json::json!({"problems": [ + {"id": "bad", "kind": kind, "args": args, "statement": "s"} + ]}); + let rejected = std::panic::catch_unwind(|| problems(&sim).is_err()); + assert_eq!(rejected.ok(), Some(true), "{kind} {args}"); + } +} + +#[test] +fn the_problem_bank_still_computes_large_but_fitting_answers() -> Result<()> { + let sim = serde_json::json!({"problems": [ + {"id": "wide", "kind": "lattice", "args": [33, 33], "statement": "s"}, + {"id": "pow", "kind": "digitsum", "args": [2, 63], "statement": "s"} + ]}); + let bank = problems(&sim)?; + assert_eq!(bank[0].answer()?, 7_219_428_434_016_265_740); + assert_eq!(bank[1].answer()?, 89); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn the_loader_refuses_a_symlinked_package_file() -> Result<()> { + let dir = std::env::temp_dir().join(format!("hive_view-symlink-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir)?; + let outside = dir.join("outside.txt"); + std::fs::write(&outside, "secret")?; + let package = dir.join("package"); + std::fs::create_dir_all(&package)?; + std::fs::write(package.join("hive.json"), "{}")?; + std::os::unix::fs::symlink(&outside, package.join("README.md"))?; + let error = crate::load::documents(&package) + .err() + .map(|error| error.to_string()); + std::fs::remove_dir_all(&dir)?; + assert!(error.is_some_and(|error| error.contains("symbolic link"))); + Ok(()) +} + +#[test] +fn the_problem_bank_refuses_computations_beyond_its_work_limits() { + for (kind, args) in [ + ("primes", serde_json::json!([100_001])), + ("multiples", serde_json::json!([1_000_001, 3, 5])), + ("sqdiff", serde_json::json!([10_001])), + ("modsum", serde_json::json!([1_001, 1_000, 7])), + ("modsum", serde_json::json!([1_000_001, 0, 7])), + ] { + let sim = serde_json::json!({"problems": [ + {"id": "too-much-work", "kind": kind, "args": args, "statement": "s"} + ]}); + let error = problems(&sim).err().map(|error| error.to_string()); + assert!( + error.is_some_and(|error| error.contains("work limit")), + "{kind} {args}" + ); + } +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/test/record.rs b/crates/tinyhivemind-lang/examples/hive_view/test/record.rs new file mode 100644 index 00000000..c8765615 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/test/record.rs @@ -0,0 +1,138 @@ +//! Recording a run: frames go to the recording, flushed one line at a time, +//! whether or not stdout carries them, and `--record` parses only with a run. + +use std::cell::RefCell; +use std::io::Write; +use std::rc::Rc; + +use crate::Result; +use crate::frames::Frame; +use crate::output::FrameWriter; + +/// A writer that keeps what was flushed apart from what was only written. +#[derive(Clone, Default)] +struct Probe { + written: Rc>>, + flushed: Rc>, +} + +impl Write for Probe { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.written.borrow_mut().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + *self.flushed.borrow_mut() = self.written.borrow().len(); + Ok(()) + } +} + +fn end() -> Frame { + Frame::End { + ending: "converged".into(), + topic: Some("merge".into()), + correct: None, + rounds: 1, + turns: 2, + desk: None, + } +} + +#[test] +fn a_recording_gets_every_frame_flushed_as_it_is_written() -> Result<()> { + let probe = Probe::default(); + let mut writer = FrameWriter { + stdout: true, + kept: Some(Box::new(probe.clone())), + }; + let mut out = Vec::new(); + writer.emit(&mut out, &end())?; + let kept = String::from_utf8(probe.written.borrow().clone())?; + assert_eq!(kept, String::from_utf8(out)?); + assert_eq!( + *probe.flushed.borrow(), + kept.len(), + "a follower must see the line now" + ); + assert!(kept.starts_with(r#"{"type":"end""#) && kept.ends_with('\n')); + Ok(()) +} + +#[test] +fn a_human_run_still_records_frames_but_prints_none() -> Result<()> { + let probe = Probe::default(); + let mut writer = FrameWriter { + stdout: false, + kept: Some(Box::new(probe.clone())), + }; + let mut out = Vec::new(); + writer.emit(&mut out, &end())?; + assert_eq!((out).len(), 0); + let kept = String::from_utf8(probe.written.borrow().clone())?; + assert_eq!(kept.lines().count(), 1); + Ok(()) +} + +#[test] +fn record_parses_as_a_flag() -> Result<()> { + let parse = |argv: &[&str]| crate::parse_args(argv.iter().map(|arg| (*arg).to_owned())); + assert!(parse(&["dir", "--run", "--record"])?.output.record); + assert!(!parse(&["dir", "--run"])?.output.record); + Ok(()) +} + +#[test] +fn a_run_label_is_one_safe_path_component() { + use crate::output::run_label; + for id in ["../../etc", "a/b", "/abs/path", "..", "x\\y"] { + let label = run_label(id, 1); + assert!(!label.contains('/') && !label.contains('\\'), "{label}"); + assert_eq!( + std::path::Path::new(&label).components().count(), + 1, + "{label}" + ); + assert!(!label.starts_with('.'), "{label}"); + } +} + +#[test] +fn run_labels_started_together_never_collide() { + use crate::output::run_label; + let labels: std::collections::BTreeSet = + (0..1000).map(|_| run_label("pkg", 1)).collect(); + assert_eq!(labels.len(), 1000); + let prefix = |id: &str| run_label(id, 1).split("-seed").next().map(str::to_owned); + assert_ne!(prefix("a/b"), prefix("a_b")); +} + +#[test] +fn a_raised_halt_ends_the_run_before_the_next_round() -> Result<()> { + use crate::episode::{Ending, Event}; + use crate::halt::{Halt, install}; + use crate::load::load; + use crate::run::{execute, prepare}; + + use super::support::{gallery, knobs}; + + let loaded = load(&gallery("math-panel"))?; + let rounds = |halting: bool| -> Result<(u32, bool, Ending)> { + let mut prepared = prepare(&loaded, &knobs(7))?; + let halt = Halt::default(); + install(&mut prepared, halt.clone()); + let outcome = execute(&mut prepared, None, &mut |event| { + if halting && matches!(event, Event::Round(_)) { + halt.stop(); + } + })?; + Ok((outcome.rounds, outcome.spent, outcome.ending)) + }; + let (full, _, _) = rounds(false)?; + let (stopped, spent, ending) = rounds(true)?; + assert!(full > 1, "the fixture must run more than one round"); + assert_eq!(stopped, 1); + assert!(spent); + assert_eq!(ending, Ending::Exhausted); + Ok(()) +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/test/routing.rs b/crates/tinyhivemind-lang/examples/hive_view/test/routing.rs new file mode 100644 index 00000000..f1c3af44 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/test/routing.rs @@ -0,0 +1,398 @@ +//! Desk routing: one floor per desk, referrals only along declared links, +//! core's hop budget, and what this host refuses. + +use std::collections::BTreeMap; + +use tinyhivemind_core::hive::{EpisodePolicy, HiveTurn, SessionMessage, Visibility}; + +use crate::Result; +use crate::desks::{DeskEnd, FORWARD_CAP, ReferralRecord, run_hive}; +use crate::episode::{Ending, Event, RoundRecord, Speaker}; +use crate::hive::{Hive, desk_policies}; +use crate::load::{Loaded, load}; +use crate::run::{Plan, execute, prepare}; + +use super::support::{gallery, knobs}; + +/// Everything one run reported, by kind. +#[derive(Debug, Default)] +struct Seen { + rounds: Vec, + referrals: Vec, + ends: Vec, +} + +fn watch(loaded: &Loaded, at: u64) -> Result<(Seen, crate::episode::Outcome)> { + let mut prepared = prepare(loaded, &knobs(at))?; + let mut seen = Seen::default(); + let outcome = execute(&mut prepared, None, &mut |event| match event { + Event::Round(round) => seen.rounds.push(round.clone()), + Event::Referral(referral) => seen.referrals.push(referral.clone()), + Event::DeskEnd(end) => seen.ends.push(end.clone()), + })?; + Ok((seen, outcome)) +} + +fn hive_of(loaded: &Loaded) -> Result { + match crate::run::plan(loaded, &knobs(1), "Problem: pick a number. Cite ^1.".into())? { + Plan::Hive(hive) => Ok(hive), + Plan::Room(_) => Err("expected several desks".into()), + } +} + +#[test] +fn a_desk_takes_its_share_of_the_package_policy() { + let mut policy = EpisodePolicy::default(); + policy.quorum.threshold = 26; + policy.turn_budget = 150; + policy.round_width = 10; + policy.revealed_width = 12; + let desks = desk_policies(&policy, &[10, 10, 10, 10, 10]); + let desk = desks[0]; + assert_eq!(desk.quorum.threshold, 6, "26 x 10 / 50, rounded up"); + assert_eq!(desk.turn_budget, 30); + assert_eq!((desk.round_width, desk.revealed_width), (10, 10)); + let tiny = desk_policies(&policy, &[1, 49])[0]; + assert_eq!((tiny.quorum.threshold, tiny.turn_budget), (1, 3)); +} + +#[test] +fn the_desks_split_the_turn_budget_without_exceeding_it() { + let spend = |budget: u32, sizes: &[usize]| -> u32 { + let policy = EpisodePolicy { + turn_budget: budget, + ..EpisodePolicy::default() + }; + desk_policies(&policy, sizes) + .iter() + .map(|desk| desk.turn_budget) + .sum() + }; + assert_eq!(spend(60, &[1, 1, 1, 1, 1]), 60, "an even split"); + assert_eq!(spend(61, &[3, 3, 3, 3, 3]), 61); + assert_eq!( + spend(1, &[2, 2, 2, 2, 2]), + 1, + "one turn is one turn, not five" + ); + assert_eq!(spend(0, &[2, 3]), 0); + assert_eq!(spend(7, &[5, 1, 1]), 7); +} + +#[test] +fn every_desk_of_the_swarm_runs_its_own_floor_and_the_last_one_answers() -> Result<()> { + let loaded = load(&gallery("math-swarm"))?; + let desk_of: BTreeMap<&str, &str> = loaded + .lowered + .desks + .iter() + .flat_map(|desk| { + desk.members + .iter() + .map(move |member| (member.as_str(), desk.id.as_str())) + }) + .collect(); + for seed in 1..=5 { + let (seen, outcome) = watch(&loaded, seed)?; + for round in &seen.rounds { + for turn in &round.turns { + assert_eq!( + desk_of.get(turn.seat.as_str()), + Some(&round.desk.as_str()), + "seed {seed}: {} spoke on {}", + turn.seat, + round.desk + ); + } + } + let ended: Vec<&str> = seen.ends.iter().map(|end| end.desk.as_str()).collect(); + assert_eq!( + ended, + ["conjecture", "compute", "prove", "verify", "synthesis"], + "seed {seed}: a downstream desk opens only once its upstream desks ended" + ); + let synthesis = seen.ends.last().ok_or("a synthesis ending")?; + let expected = match &synthesis.ending { + Ending::Unopened => Ending::Exhausted, + other => other.clone(), + }; + assert_eq!(outcome.ending, expected, "seed {seed}"); + let rounds = seen.rounds.iter().map(|round| round.round); + assert!(rounds.clone().eq(1..=outcome.rounds), "seed {seed}"); + } + Ok(()) +} + +#[test] +fn referrals_land_only_along_declared_links() -> Result<()> { + let loaded = load(&gallery("math-swarm"))?; + let hive = hive_of(&loaded)?; + for seed in 1..=10 { + let (seen, _) = watch(&loaded, seed)?; + assert!(seen.referrals.iter().any(|referral| referral.accepted)); + for referral in seen.referrals.iter().filter(|referral| referral.accepted) { + let link = (referral.from_desk.clone(), referral.to_desk.clone()); + assert!(hive.links.contains(&link), "seed {seed}: {referral:?}"); + assert!(referral.hops <= loaded.lowered.policies.referral.max_hops); + assert!(referral.sequence.is_some() && referral.reason.is_none()); + } + } + Ok(()) +} + +#[test] +fn a_report_past_max_hops_is_refused_by_core() -> Result<()> { + let loaded = load(&gallery("math-swarm"))?; + let (seen, _) = watch(&loaded, 3)?; + let refused: Vec<&ReferralRecord> = seen + .referrals + .iter() + .filter(|referral| referral.from_desk == "synthesis") + .collect(); + assert_eq!(refused.len(), 2, "synthesis reports back along two links"); + for referral in refused { + assert!(!referral.accepted); + assert_eq!(referral.reason.as_deref(), Some("hop_limit_reached")); + assert_eq!(referral.hops, loaded.lowered.policies.referral.max_hops); + } + // With a budget of one hop a desk that received a report cannot pass + // one on; a desk whose upstream reports were refused starts afresh. + let mut shallow = load(&gallery("math-swarm"))?; + shallow.lowered.policies.referral.max_hops = 1; + let (seen, _) = watch(&shallow, 3)?; + for referral in &seen.referrals { + if referral.accepted { + assert_eq!(referral.hops, 1, "{referral:?}"); + } else { + // Feedback to a desk that already ended is refused by this host. + let reason = referral.reason.as_deref(); + assert!( + matches!(reason, Some("hop_limit_reached" | "desk_closed")), + "{referral:?}" + ); + } + } + let from = |desk: &str| -> Vec { + seen.referrals + .iter() + .filter(|referral| referral.from_desk == desk) + .map(|referral| referral.accepted) + .collect() + }; + assert_eq!(from("conjecture"), [true, true]); + assert_eq!(from("compute"), [false, false]); + Ok(()) +} + +#[test] +fn a_disabled_policy_refuses_every_report_and_desks_decide_alone() -> Result<()> { + let mut loaded = load(&gallery("math-swarm"))?; + loaded.lowered.policies.referral.enabled = false; + let (seen, _) = watch(&loaded, 2)?; + assert_ne!((seen.referrals).len(), 0); + assert!( + seen.referrals + .iter() + .all(|referral| !referral.accepted && referral.reason.as_deref() == Some("disabled")) + ); + assert_eq!(seen.ends.len(), 5); + Ok(()) +} + +#[test] +fn a_one_desk_package_routes_nothing_and_reports_its_own_desk() -> Result<()> { + let loaded = load(&gallery("math-panel"))?; + let (seen, _) = watch(&loaded, 4)?; + assert_eq!((seen.referrals).len(), 0); + assert_eq!(seen.ends.len(), 0); + assert!(seen.rounds.iter().all(|round| round.desk == "panel" + && round.desk_round == round.round + && round.phase.is_some())); + Ok(()) +} + +/// A speaker whose seats ask other desks: `asks` maps a seat to the line it +/// writes on its first turn; everyone else proposes `#7`. +struct Asking { + asks: BTreeMap, + answered: Vec<(String, String)>, +} + +impl Speaker for Asking { + fn speak( + &mut self, + turn: &HiveTurn, + _visible: &[SessionMessage], + _policy: &EpisodePolicy, + ) -> String { + if let Some(line) = self.asks.remove(&turn.agent_id) { + return line; + } + if turn.visibility == Visibility::Blind { + "!propose #7 ^1 Seven.".into() + } else { + "!question Nothing further.".into() + } + } + + fn answer( + &mut self, + seat: &str, + incoming: &str, + _visible: &[SessionMessage], + ) -> Result { + self.answered.push((seat.to_owned(), incoming.to_owned())); + Ok("!evidence ^1 It is seven.".into()) + } +} + +fn asking(loaded: &Loaded, asks: &[(&str, &str)]) -> Result<(Seen, Asking)> { + let hive = hive_of(loaded)?; + let mut speaker = Asking { + asks: asks + .iter() + .map(|(seat, line)| ((*seat).to_owned(), (*line).to_owned())) + .collect(), + answered: Vec::new(), + }; + let mut seen = Seen::default(); + run_hive(&hive, &mut speaker, None, &mut |event| match event { + Event::Round(round) => seen.rounds.push(round.clone()), + Event::Referral(referral) => seen.referrals.push(referral.clone()), + Event::DeskEnd(end) => seen.ends.push(end.clone()), + })?; + Ok((seen, speaker)) +} + +#[test] +fn a_linked_ask_is_answered_on_the_far_desk_and_returns_home() -> Result<()> { + let loaded = load(&gallery("math-swarm"))?; + let (seen, speaker) = asking( + &loaded, + &[( + "conjecture-01", + "!propose #7 ^1 Seven?\n@#verify can you check seven?", + )], + )?; + let forward = seen + .referrals + .iter() + .find(|referral| referral.kind == "forward") + .ok_or("the ask was routed")?; + assert!(forward.accepted, "{forward:?}"); + assert_eq!( + (forward.from_desk.as_str(), forward.to_desk.as_str()), + ("conjecture", "verify") + ); + assert_eq!(forward.hops, 1); + let target = forward.target.clone().ok_or("core chose a seat")?; + assert!(target.starts_with("verify-")); + assert_eq!(speaker.answered.len(), 1); + assert_eq!(speaker.answered[0].0, target); + let answer = seen + .rounds + .iter() + .find(|round| round.phase.is_none()) + .ok_or("an answer round")?; + assert_eq!(answer.desk, "verify"); + assert_eq!(answer.turns[0].reason, "referral"); + let back = seen + .referrals + .iter() + .find(|referral| referral.kind == "return") + .ok_or("the answer was routed back")?; + assert_eq!( + (back.from_desk.as_str(), back.to_desk.as_str()), + ("verify", "conjecture") + ); + assert_eq!(back.hops, 2); + assert!( + back.accepted || back.reason.as_deref() == Some("desk_closed"), + "{back:?}" + ); + Ok(()) +} + +#[test] +fn an_ask_to_an_unlinked_desk_is_refused() -> Result<()> { + let loaded = load(&gallery("math-swarm"))?; + let (seen, speaker) = asking( + &loaded, + &[( + "conjecture-01", + "!propose #7 ^1 Seven.\n@#synthesis is it seven?", + )], + )?; + let refused = seen + .referrals + .iter() + .find(|referral| referral.kind == "forward") + .ok_or("the ask was considered")?; + assert!(!refused.accepted); + assert_eq!(refused.reason.as_deref(), Some("unlinked")); + assert_eq!(refused.to_desk, "synthesis"); + assert_eq!((speaker.answered).len(), 0); + Ok(()) +} + +#[test] +fn a_desk_may_ask_only_a_constant_number_of_times() -> Result<()> { + let loaded = load(&gallery("math-swarm"))?; + let asks: Vec<(String, String)> = (1..=5) + .map(|n| { + ( + format!("conjecture-{n:02}"), + format!("!propose #7 ^1 Seven.\n@#compute question {n}"), + ) + }) + .collect(); + let borrowed: Vec<(&str, &str)> = asks + .iter() + .map(|(seat, line)| (seat.as_str(), line.as_str())) + .collect(); + let (seen, _) = asking(&loaded, &borrowed)?; + let forwards: Vec<&ReferralRecord> = seen + .referrals + .iter() + .filter(|referral| referral.kind == "forward") + .collect(); + assert_eq!(forwards.len(), 5); + let accepted = forwards.iter().filter(|referral| referral.accepted).count(); + assert_eq!(accepted, FORWARD_CAP as usize); + assert!( + forwards + .iter() + .filter(|referral| !referral.accepted) + .all(|referral| referral.reason.as_deref() == Some("width_cap")) + ); + Ok(()) +} + +#[test] +fn referral_answers_cannot_exceed_the_shared_turn_budget() -> Result<()> { + let loaded = load(&gallery("math-swarm"))?; + let mut hive = hive_of(&loaded)?; + for room in &mut hive.rooms { + room.policy.turn_budget = 0; + } + hive.rooms[0].policy.turn_budget = 1; + hive.rooms[0].policy.round_width = 1; + let mut speaker = Asking { + asks: BTreeMap::from([( + "conjecture-01".into(), + "!propose #7 ^1 Seven?\n@#verify can you check seven?".into(), + )]), + answered: Vec::new(), + }; + let outcome = run_hive(&hive, &mut speaker, None, &mut |_| {})?; + assert_eq!( + outcome.turns, 1, + "the asking turn uses the entire hive budget" + ); + assert!( + speaker.answered.is_empty(), + "an answer requires another turn" + ); + assert!(outcome.spent); + Ok(()) +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/test/runs.rs b/crates/tinyhivemind-lang/examples/hive_view/test/runs.rs new file mode 100644 index 00000000..b7da1be7 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/test/runs.rs @@ -0,0 +1,101 @@ +//! Offline episodes: scripted, seeded and simulated, and their determinism. + +use crate::Result; +use crate::episode::{Ending, Event}; +use crate::load::load; +use crate::run::{Plan, execute, prepare}; + +use super::support::{ending, gallery, knobs}; + +#[test] +fn review_board_converges_on_merge_under_its_script() -> Result<()> { + for seed in 1..=3 { + assert_eq!( + ending("review-board", seed)?, + Ending::Converged("merge".into()) + ); + } + Ok(()) +} + +#[test] +fn the_seeded_default_script_ends_differently_on_different_seeds() -> Result<()> { + let endings: Vec = (1..=3) + .map(|seed| ending("incident-response", seed)) + .collect::>()?; + assert!( + endings.windows(2).any(|pair| pair[0] != pair[1]), + "{endings:?}" + ); + // And the same seed always ends the same way. + assert_eq!(ending("incident-response", 2)?, endings[1]); + Ok(()) +} + +#[test] +fn a_math_swarm_episode_ends_within_its_turn_budget() -> Result<()> { + let loaded = load(&gallery("math-swarm"))?; + for seed in 1..=10 { + let mut prepared = prepare(&loaded, &knobs(seed))?; + assert_eq!(prepared.seats(), 50); + let Plan::Hive(hive) = &prepared.plan else { + return Err("math-swarm runs one floor per desk".into()); + }; + let budget: u32 = hive.rooms.iter().map(|room| room.policy.turn_budget).sum(); + let mut widest = 0; + let mut answers = 0; + let outcome = execute(&mut prepared, None, &mut |event| { + if let Event::Round(round) = event { + widest = widest.max(round.turns.len()); + answers += usize::from(round.phase.is_none()); + } + })?; + assert_eq!(answers, 0, "the simulation never asks another desk"); + assert!( + outcome.turns <= budget, + "seed {seed}: {} turns over {budget}", + outcome.turns + ); + assert!(widest <= 10, "a round wider than round_width"); + assert_ne!(outcome.ending, Ending::Idle); + } + Ok(()) +} + +#[test] +fn the_same_seed_replays_the_same_frames() -> Result<()> { + let loaded = load(&gallery("math-panel"))?; + let transcript = |seed| -> Result> { + let mut prepared = prepare(&loaded, &knobs(seed))?; + let mut lines = Vec::new(); + execute(&mut prepared, None, &mut |event| { + if let Event::Round(round) = event { + lines.extend(round.turns.iter().map(|turn| turn.content.clone())); + } + })?; + Ok(lines) + }; + assert_eq!(transcript(7)?, transcript(7)?); + Ok(()) +} + +#[test] +fn simulation_error_weights_must_be_parts_per_million_and_not_all_zero() -> Result<()> { + use crate::sim::SimConfig; + + let base = load(&gallery("math-panel"))? + .sim + .ok_or("math-panel carries a sim.json")?; + let with = |classic: u64, plus_one: u64, minus_one: u64| { + let mut sim = base.clone(); + sim["errors"] = serde_json::json!({ + "classic": classic, "plus_one": plus_one, "minus_one": minus_one + }); + SimConfig::from(&sim) + }; + assert!(with(500_000, 250_000, 250_000).is_ok()); + assert!(with(u64::MAX, 1, 0).is_err(), "a sum that would overflow"); + assert!(with(1_000_001, 0, 0).is_err(), "above one million"); + assert!(with(0, 0, 0).is_err(), "no wrong answer to draw"); + Ok(()) +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/test/support.rs b/crates/tinyhivemind-lang/examples/hive_view/test/support.rs new file mode 100644 index 00000000..9f72a0d6 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/test/support.rs @@ -0,0 +1,30 @@ +//! Fixtures shared by the test modules. + +use std::path::{Path, PathBuf}; + +use crate::Result; +use crate::episode::{Ending, Event}; +use crate::load::load; +use crate::run::{Knobs, execute, prepare}; + +/// A gallery package's directory. +pub(super) fn gallery(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples/hives") + .join(name) +} + +/// Default knobs at one seed. +pub(super) fn knobs(seed: u64) -> Knobs { + Knobs { + seed, + ..Knobs::default() + } +} + +/// How a gallery package ends at one seed. +pub(super) fn ending(name: &str, seed: u64) -> Result { + let loaded = load(&gallery(name))?; + let mut prepared = prepare(&loaded, &knobs(seed))?; + Ok(execute(&mut prepared, None, &mut |_: Event<'_>| {})?.ending) +} diff --git a/crates/tinyhivemind-lang/examples/hive_view/test/wire.rs b/crates/tinyhivemind-lang/examples/hive_view/test/wire.rs new file mode 100644 index 00000000..e5933d85 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hive_view/test/wire.rs @@ -0,0 +1,218 @@ +//! The pinned NDJSON wire form: every frame the viewer reads. + +use serde_json::json; + +use crate::Result; +use crate::compare::BASELINE; +use crate::frames::{ + CompareFrame, Frame, ProblemFrame, ReferralFrame, SeedResult, StandingFrame, SummaryRow, + TurnFrame, UsageFrame, +}; + +#[test] +fn frames_pin_their_wire_form() -> Result<()> { + let start = Frame::Start { + package: "math-panel".into(), + seed: 3, + seats: 5, + problem: Some(ProblemFrame { + id: "lattice-6x6".into(), + statement: "s".into(), + answer: "924".into(), + }), + live: None, + model: None, + }; + assert_eq!( + serde_json::to_value(&start)?, + json!({"type":"start","package":"math-panel","seed":3,"seats":5, + "problem":{"id":"lattice-6x6","statement":"s","answer":"924"}}) + ); + let round = Frame::Round { + round: 1, + desk: "panel".into(), + desk_round: 1, + phase: "deliberate".into(), + visibility: "blind".into(), + turns: vec![TurnFrame { + seat: "checker".into(), + desk: "panel".into(), + reason: "salience".into(), + saw: 1, + of: 4, + content: "!propose #924 ^1".into(), + answer: Some("924".into()), + correct: Some(true), + sequence: 5, + }], + standings: vec![StandingFrame { + topic: "924".into(), + support: 2, + object: 1, + committed: false, + supporters: vec!["checker".into(), "lead".into()], + }], + }; + assert_eq!( + serde_json::to_value(&round)?, + json!({"type":"round","round":1,"desk":"panel","desk_round":1,"phase":"deliberate","visibility":"blind", + "turns":[{"seat":"checker","desk":"panel","reason":"salience","saw":1,"of":4, + "content":"!propose #924 ^1","answer":"924","correct":true,"sequence":5}], + "standings":[{"topic":"924","support":2,"object":1,"committed":false, + "supporters":["checker","lead"]}]}) + ); + let end = Frame::End { + ending: "exhausted".into(), + topic: None, + correct: None, + rounds: 7, + turns: 58, + desk: None, + }; + assert_eq!( + serde_json::to_value(&end)?, + json!({"type":"end","ending":"exhausted","topic":null,"correct":null,"rounds":7,"turns":58}) + ); + Ok(()) +} + +#[test] +fn compare_frames_pin_their_wire_form() -> Result<()> { + let seed = CompareFrame::Seed { + seed: 1, + problem: "primes-200".into(), + results: vec![SeedResult { + package: BASELINE.into(), + seats: 1, + ending: "converged".into(), + correct: false, + rounds: 2, + turns: 2, + topic: Some("47".into()), + }], + }; + assert_eq!( + serde_json::to_value(&seed)?, + json!({"type":"seed","seed":1,"problem":"primes-200","results":[ + {"package":"single-seat","seats":1,"ending":"converged","correct":false,"rounds":2,"turns":2,"topic":"47"}]}) + ); + let summary = CompareFrame::Summary { + seeds: 2, + rows: vec![SummaryRow { + package: "math-swarm".into(), + seats: 50, + accuracy: 0.5, + converged: 1.0, + deadlocked: 0.0, + exhausted: 0.0, + idle: 0.0, + mean_rounds: 6.5, + mean_turns: 51.0, + }], + }; + assert_eq!( + serde_json::to_value(&summary)?, + json!({"type":"summary","seeds":2,"rows":[{"package":"math-swarm","seats":50,"accuracy":0.5, + "converged":1.0,"deadlocked":0.0,"exhausted":0.0,"idle":0.0,"mean_rounds":6.5,"mean_turns":51.0}]}) + ); + Ok(()) +} + +#[test] +fn routing_frames_pin_their_wire_form() -> Result<()> { + let accepted = Frame::Referral(ReferralFrame { + round: 10, + kind: "report".into(), + from_desk: "verify".into(), + to_desk: "synthesis".into(), + seat: "verify-01".into(), + target: Some("synthesis-01".into()), + content: "!propose #924 ^1 Verify settled on 924.\n@#synthesis".into(), + hops: 4, + accepted: true, + reason: None, + sequence: Some(2), + }); + assert_eq!( + serde_json::to_value(&accepted)?, + json!({"type":"referral","round":10,"kind":"report","from_desk":"verify","to_desk":"synthesis", + "seat":"verify-01","target":"synthesis-01","content":"!propose #924 ^1 Verify settled on 924.\n@#synthesis", + "hops":4,"accepted":true,"sequence":2}) + ); + let refused = Frame::Referral(ReferralFrame { + round: 12, + kind: "report".into(), + from_desk: "synthesis".into(), + to_desk: "conjecture".into(), + seat: "synthesis-01".into(), + target: None, + content: "c".into(), + hops: 4, + accepted: false, + reason: Some("hop_limit_reached".into()), + sequence: None, + }); + assert_eq!( + serde_json::to_value(&refused)?, + json!({"type":"referral","round":12,"kind":"report","from_desk":"synthesis","to_desk":"conjecture", + "seat":"synthesis-01","target":null,"content":"c","hops":4,"accepted":false, + "reason":"hop_limit_reached","sequence":null}) + ); + let desk_end = Frame::DeskEnd { + desk: "verify".into(), + ending: "converged".into(), + topic: Some("924".into()), + correct: Some(true), + rounds: 2, + turns: 11, + }; + assert_eq!( + serde_json::to_value(&desk_end)?, + json!({"type":"desk_end","desk":"verify","ending":"converged","topic":"924","correct":true,"rounds":2,"turns":11}) + ); + let end = Frame::End { + ending: "converged".into(), + topic: Some("924".into()), + correct: Some(true), + rounds: 12, + turns: 75, + desk: Some("synthesis".into()), + }; + assert_eq!( + serde_json::to_value(&end)?, + json!({"type":"end","ending":"converged","topic":"924","correct":true,"rounds":12,"turns":75,"desk":"synthesis"}) + ); + Ok(()) +} + +#[test] +fn live_frames_pin_their_wire_form() -> Result<()> { + let start = Frame::Start { + package: "math-panel".into(), + seed: 1, + seats: 5, + problem: None, + live: Some(true), + model: Some("openai/gpt-oss-120b:nitro".into()), + }; + assert_eq!( + serde_json::to_value(&start)?, + json!({"type":"start","package":"math-panel","seed":1,"seats":5,"problem":null, + "live":true,"model":"openai/gpt-oss-120b:nitro"}) + ); + let usage = Frame::Usage(UsageFrame { + calls: 12, + prompt_tokens: 9000, + completion_tokens: 800, + total_tokens: 9800, + cost_usd: 0.0015, + wall_ms: 4200, + exhausted_calls: false, + }); + assert_eq!( + serde_json::to_value(&usage)?, + json!({"type":"usage","calls":12,"prompt_tokens":9000,"completion_tokens":800,"total_tokens":9800, + "cost_usd":0.0015,"wall_ms":4200,"exhausted_calls":false}) + ); + Ok(()) +} diff --git a/crates/tinyhivemind-lang/examples/hives/README.md b/crates/tinyhivemind-lang/examples/hives/README.md new file mode 100644 index 00000000..30dd5b1f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/README.md @@ -0,0 +1,29 @@ +# Example hive packages + +A gallery of complete packages in directory form. Between them they use every +part of the language; each one's `README.md` opens with what it demonstrates. +`tests/gallery_packages.rs` parses and lowers every one, and +[`hive_view`](../hive_view/README.md) renders and runs them offline. + +| Package | Seats | Desks | What it shows | +| --- | --- | --- | --- | +| `review-board/` | 3 | 1 | The minimal hive: blind round, grounded quorum, a replayed `script.json` | +| `research-debate/` | 4 | 1 | Goals with subgoals, missions, an itemized `context/` document, `responder_mode: auto` | +| `incident-response/` | 6 | 2 | Topology, referrals with a hop limit, an approval policy, private asides | +| `memory-guild/` | 4 | 1 | Agent, pool, forked, hive-lifetime and read-only memories; bindings; JSON frontmatter | +| `self-improving/` | 3 | 1 | A constitution with permitted edits, canaries and protected namespaces | +| `math-panel/` | 5 | 1 | Five unreliable seats solving exact problems (`sim.json`) | +| `math-swarm/` | 50 | 5 | Fifty seats in five linked desks on the same problems, rounds of ten | +| `euler-hive/` | 14 | 5 | A live, problem-agnostic hive: per-seat tool access (web search on research only, Python on compute and verify only), referrals, an agent memory per seat and a shared verified pool. Its own test is `tests/euler_hive_package.rs`. | + +`sim.json` and `script.json` are neither canonical nor Markdown, so `parse` +drops them: they belong to the example, not to the package. + +No seat may reach the web: every seat's `tools.deny` lists `web_search`, +`web_fetch`, `browser` and `http`, and no `allow` list names one. The one +exception is `euler-hive`, whose research-desk seats opt in to `web_search` +(and only that) to search the literature. `tests/gallery_packages.rs` reads +every package directory and asserts both. A package of +several desks runs one floor per desk in `hive_view`, linked only by referral +along its declared topology and referrals. The `README.md` +files are accepted as unreferenced Markdown documents. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/README.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/README.md new file mode 100644 index 00000000..1ad70d7b --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/README.md @@ -0,0 +1,51 @@ +# euler-hive + +A live, problem-agnostic hive for hard mathematical problems: fourteen persona +seats on five desks run as a pipeline — research, theory, compute, verify, +synthesis — joined by referrals, with web search only on research seats, +Python only on compute and verify seats, and a CortexDB memory bound to every +seat. The committed `context/problem.md` is a placeholder; a host replaces it +with the statement it is handed, so the package names no problem and carries +none of a problem's data. + +`examples/openhuman --bin euler_hive` is the host that runs it live +(OpenRouter models, TinyHumans SDK search, a Docker sandbox); see +[its README](../../../../../examples/openhuman/src/bin/euler_hive/README.md). +`tests/euler_hive_package.rs` pins the package's shape and gating. + +| Desk | Seats | Tools (besides the hive's own) | Job | +| --- | --- | --- | --- | +| `research` | librarian, provenance skeptic, algorithmic scout | `web_search` | Find the theory and literature; report sources, never a copied answer | +| `theory` | combinatorial-design geometer, linear-algebra and coding theorist, counting auditor | none | Identify the structure and hand compute a checkable plan | +| `compute` | exhaustive-search engineer, GF(2) programmer, brute-force minimalist | `run_python` | Exact programs, run in the sandbox, outputs verbatim | +| `verify` | constraint auditor, independent re-deriver, adversarial uniqueness tester | `run_python` | Re-derive every candidate, reproduce every sample value the statement gives, check each constraint | +| `synthesis` | evidence-weighing judge, dissent recorder | none | Commit the answer once verify signs off | + +Every seat also holds the hive's own tools: `hive_broadcast` (a desk +colleague), `hive_refer` (a linked desk), `hive_complete` (finish with a +report, an optional `candidate` and a `verdict`), `hive_remember` and +`hive_recall`. Every seat denies `shell`, file access, `web_fetch`, `browser`, +`http`, `glob`, `grep` and `git`, and every seat off the research desk denies +`web_search` too. + +**Topology** is the pipeline `research -> theory -> compute -> verify -> +synthesis`. **Referrals** add the way back: theory to research, compute to +theory, verify to compute and theory, synthesis to verify. Referral is enabled +(`reach: desks`, `returns: true`, `max_hops: 6`), rounds are at most three +seats wide, and the constitution caps a run at 80 turns. + +**Memory.** The root is `team:euler-hive`. Each seat binds its own agent +identity at `team:euler-hive/agent:` and reads it and the shared +`team:euler-hive/project:verified` pool, recalls at session start, rejoin and +compaction within 2,500 characters, and may remember observations, failed +attempts, outcomes and notes. Every identity has `lifecycle: run`, so a host +scopes a run's memory to that run. + +| Path | Purpose | +| --- | --- | +| `hive.json` | Five desks, fourteen seats with per-seat tool access, goals, missions, topology, referrals, policies | +| `constitution.json` | `max_turns` 80, `max_width` 3, the two hard constraints on web and file access | +| `memory.json` | The root, one agent identity per seat, the verified pool, and fourteen bindings | +| `roles/` | One charter per role | +| `seats/` | Fourteen prompts, each opening with a distinct `You are a "".` | +| `context/problem.md` | The placeholder a host replaces with the problem statement | diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/constitution.json b/crates/tinyhivemind-lang/examples/hives/euler-hive/constitution.json new file mode 100644 index 00000000..834e04db --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/constitution.json @@ -0,0 +1,15 @@ +{ + "hard_constraints": [ + "no seat but a research seat may reach the web", + "no seat may read the host filesystem" + ], + "max_turns": 80, + "max_width": 3, + "permitted_edits": [], + "read_only_memories": [], + "protected_namespaces": [], + "contamination_canaries": [], + "evaluation": null, + "acceptance": null, + "telemetry": null +} diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/context/problem.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/context/problem.md new file mode 100644 index 00000000..ab7de15c --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/context/problem.md @@ -0,0 +1,6 @@ +# Problem + + +This is a placeholder. The host replaces this document at run time with the +statement it was given, converted to Markdown, so the committed package names +no problem and carries none of its data. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/hive.json b/crates/tinyhivemind-lang/examples/hives/euler-hive/hive.json new file mode 100644 index 00000000..2ca77253 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/hive.json @@ -0,0 +1,830 @@ +{ + "$schema": "https://tinyhumans.ai/schemas/hive/v1", + "version": 1, + "id": "euler-hive", + "name": "Euler hive", + "roles": [ + { + "id": "researcher", + "charter": "roles/researcher.md" + }, + { + "id": "theorist", + "charter": "roles/theorist.md" + }, + { + "id": "computer", + "charter": "roles/computer.md" + }, + { + "id": "verifier", + "charter": "roles/verifier.md" + }, + { + "id": "synthesizer", + "charter": "roles/synthesizer.md" + } + ], + "seats": [ + { + "id": "research-librarian", + "label": "research-librarian", + "role": "researcher", + "prompt": "seats/research-librarian.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "web_search", + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "run_python" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "research-provenance", + "label": "research-provenance", + "role": "researcher", + "prompt": "seats/research-provenance.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "web_search", + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "run_python" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "research-scout", + "label": "research-scout", + "role": "researcher", + "prompt": "seats/research-scout.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "web_search", + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "run_python" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "theory-designer", + "label": "theory-designer", + "role": "theorist", + "prompt": "seats/theory-designer.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "web_search", + "run_python" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "theory-coder", + "label": "theory-coder", + "role": "theorist", + "prompt": "seats/theory-coder.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "web_search", + "run_python" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "theory-counter", + "label": "theory-counter", + "role": "theorist", + "prompt": "seats/theory-counter.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "web_search", + "run_python" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-exhaustive", + "label": "compute-exhaustive", + "role": "computer", + "prompt": "seats/compute-exhaustive.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "run_python", + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "web_search" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-linear", + "label": "compute-linear", + "role": "computer", + "prompt": "seats/compute-linear.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "run_python", + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "web_search" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-minimalist", + "label": "compute-minimalist", + "role": "computer", + "prompt": "seats/compute-minimalist.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "run_python", + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "web_search" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-constraints", + "label": "verify-constraints", + "role": "verifier", + "prompt": "seats/verify-constraints.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "run_python", + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "web_search" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-rederiver", + "label": "verify-rederiver", + "role": "verifier", + "prompt": "seats/verify-rederiver.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "run_python", + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "web_search" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-adversary", + "label": "verify-adversary", + "role": "verifier", + "prompt": "seats/verify-adversary.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "run_python", + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "web_search" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "synthesis-judge", + "label": "synthesis-judge", + "role": "synthesizer", + "prompt": "seats/synthesis-judge.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "web_search", + "run_python" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "synthesis-dissent", + "label": "synthesis-dissent", + "role": "synthesizer", + "prompt": "seats/synthesis-dissent.md", + "template": "euler-seat", + "config": { + "max_iterations": 10, + "temperature": 0.2 + }, + "model": null, + "tools": { + "allow": [ + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall" + ], + "deny": [ + "shell", + "file_read", + "file_write", + "file_edit", + "web_fetch", + "browser", + "http", + "glob", + "grep", + "git", + "web_search", + "run_python" + ] + }, + "mcp": [], + "skills": [] + } + ], + "desks": [ + { + "id": "research", + "name": "Research", + "description": "Find the theory and literature the problem rests on; report sources, never a copied answer", + "members": [ + "research-librarian", + "research-provenance", + "research-scout" + ], + "responder_mode": "auto" + }, + { + "id": "theory", + "name": "Theory", + "description": "Identify the combinatorial structure, prove what it forces, and turn it into a checkable plan", + "members": [ + "theory-designer", + "theory-coder", + "theory-counter" + ], + "responder_mode": "auto" + }, + { + "id": "compute", + "name": "Compute", + "description": "Turn the plan into exact programs, run them in the sandbox, and report outputs verbatim", + "members": [ + "compute-exhaustive", + "compute-linear", + "compute-minimalist" + ], + "responder_mode": "auto" + }, + { + "id": "verify", + "name": "Verify", + "description": "Re-derive every candidate independently and check it against each stated constraint", + "members": [ + "verify-constraints", + "verify-rederiver", + "verify-adversary" + ], + "responder_mode": "auto" + }, + { + "id": "synthesis", + "name": "Synthesis", + "description": "Commit the hive's answer once verification has signed it off", + "members": [ + "synthesis-judge", + "synthesis-dissent" + ], + "responder_mode": "auto" + } + ], + "goals": [ + { + "id": "answer", + "description": "Commit the exact answer to the problem in context/problem.md, in the format it asks for", + "acceptance": [ + "Signed off by the verify desk with sandbox output", + "Every stated constraint checked separately" + ], + "subgoals": [ + "structure", + "candidate" + ] + }, + { + "id": "structure", + "description": "Identify the structure the problem describes and what determines the answer", + "acceptance": [ + "Cites at least one source or a derivation" + ], + "subgoals": [] + }, + { + "id": "candidate", + "description": "Compute a candidate answer exactly in the sandbox", + "acceptance": [ + "Printed by a program the hive ran" + ], + "subgoals": [] + } + ], + "missions": [ + { + "id": "survey", + "role": "researcher", + "goals": [ + "structure" + ] + }, + { + "id": "derive", + "role": "theorist", + "goals": [ + "structure" + ] + }, + { + "id": "compute", + "role": "computer", + "goals": [ + "candidate" + ] + }, + { + "id": "check", + "role": "verifier", + "goals": [ + "answer" + ] + }, + { + "id": "commit", + "role": "synthesizer", + "goals": [ + "answer" + ] + } + ], + "topology": [ + { + "from": "research", + "to": "theory" + }, + { + "from": "theory", + "to": "compute" + }, + { + "from": "compute", + "to": "verify" + }, + { + "from": "verify", + "to": "synthesis" + } + ], + "referrals": [ + { + "from": "research", + "to": "theory" + }, + { + "from": "theory", + "to": "compute" + }, + { + "from": "compute", + "to": "verify" + }, + { + "from": "verify", + "to": "synthesis" + }, + { + "from": "theory", + "to": "research" + }, + { + "from": "compute", + "to": "theory" + }, + { + "from": "verify", + "to": "compute" + }, + { + "from": "verify", + "to": "theory" + }, + { + "from": "synthesis", + "to": "verify" + } + ], + "context": [ + "context/problem.md" + ], + "policies": { + "episode": { + "turn_budget": 60, + "round_width": 3, + "revealed_width": 3, + "blind_round": true, + "dominance_cap": 50, + "repetition_cap": 3, + "distance": "sequence", + "quorum": { + "threshold": 2, + "window": 60, + "require_grounded": true + }, + "weights": { + "recency": 5, + "importance": 30, + "relevance": 20, + "half_life": 50 + } + }, + "routing": { + "minimum_confidence": 0, + "high_impact_minimum_confidence": 0, + "clarification_threshold": 1000000, + "high_impact_threshold": 1000000, + "round_width": 3, + "choice_option_limit": 8 + }, + "approval": { + "enabled": false, + "default": "deny", + "rules": [], + "approver": { + "kind": "absent" + }, + "allow_grants": false, + "max_grant_ttl": null + }, + "aside": { + "enabled": false, + "max_members": 0, + "max_messages": 0, + "must_surface": false, + "require_thread": false + }, + "referral": { + "enabled": true, + "max_hops": 6, + "reach": "desks", + "returns": true + }, + "digest": { + "keep_live": 30, + "fold_after": 20, + "fold_after_chars": 400000, + "input_limit": 60, + "budget_chars": 4000 + }, + "brevity": { + "message_chars": 1800, + "window": 30 + }, + "directory": { + "half_life": 20, + "specialisation": 30, + "credibility": 20, + "prior": 10, + "discredit": 20, + "window": 30, + "floor": 1000 + }, + "exchange": { + "enabled": false, + "contact_cap": 0, + "round_cap": 0 + }, + "division": { + "round_width": 3, + "follow_directory": true + }, + "conduct": { + "child_turn_wall": 6, + "turn_wall": 60 + } + }, + "coordinator": { + "round_width": 3, + "conduct_policy": { + "child_turn_wall": 6, + "turn_wall": 60 + }, + "broadcast_budget": 4, + "retention": { + "settled_episodes": null, + "delivered": null, + "interrupted": null, + "pending_per_agent": null + } + } +} diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/memory.json b/crates/tinyhivemind-lang/examples/hives/euler-hive/memory.json new file mode 100644 index 00000000..1b9a2e77 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/memory.json @@ -0,0 +1,519 @@ +{ + "root": "team:euler-hive", + "identities": [ + { + "id": "research-librarian-notes", + "namespace": "team:euler-hive/agent:research-librarian", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "research-provenance-notes", + "namespace": "team:euler-hive/agent:research-provenance", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "research-scout-notes", + "namespace": "team:euler-hive/agent:research-scout", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "theory-designer-notes", + "namespace": "team:euler-hive/agent:theory-designer", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "theory-coder-notes", + "namespace": "team:euler-hive/agent:theory-coder", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "theory-counter-notes", + "namespace": "team:euler-hive/agent:theory-counter", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "compute-exhaustive-notes", + "namespace": "team:euler-hive/agent:compute-exhaustive", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "compute-linear-notes", + "namespace": "team:euler-hive/agent:compute-linear", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "compute-minimalist-notes", + "namespace": "team:euler-hive/agent:compute-minimalist", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "verify-constraints-notes", + "namespace": "team:euler-hive/agent:verify-constraints", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "verify-rederiver-notes", + "namespace": "team:euler-hive/agent:verify-rederiver", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "verify-adversary-notes", + "namespace": "team:euler-hive/agent:verify-adversary", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "synthesis-judge-notes", + "namespace": "team:euler-hive/agent:synthesis-judge", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "synthesis-dissent-notes", + "namespace": "team:euler-hive/agent:synthesis-dissent", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "verified", + "namespace": "team:euler-hive/project:verified", + "kind": "pool", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + } + ], + "bindings": [ + { + "seat": "research-librarian", + "identity": "research-librarian-notes", + "reads": [ + { + "at": "team:euler-hive/agent:research-librarian", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "research-provenance", + "identity": "research-provenance-notes", + "reads": [ + { + "at": "team:euler-hive/agent:research-provenance", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "research-scout", + "identity": "research-scout-notes", + "reads": [ + { + "at": "team:euler-hive/agent:research-scout", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "theory-designer", + "identity": "theory-designer-notes", + "reads": [ + { + "at": "team:euler-hive/agent:theory-designer", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "theory-coder", + "identity": "theory-coder-notes", + "reads": [ + { + "at": "team:euler-hive/agent:theory-coder", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "theory-counter", + "identity": "theory-counter-notes", + "reads": [ + { + "at": "team:euler-hive/agent:theory-counter", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "compute-exhaustive", + "identity": "compute-exhaustive-notes", + "reads": [ + { + "at": "team:euler-hive/agent:compute-exhaustive", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "compute-linear", + "identity": "compute-linear-notes", + "reads": [ + { + "at": "team:euler-hive/agent:compute-linear", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "compute-minimalist", + "identity": "compute-minimalist-notes", + "reads": [ + { + "at": "team:euler-hive/agent:compute-minimalist", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "verify-constraints", + "identity": "verify-constraints-notes", + "reads": [ + { + "at": "team:euler-hive/agent:verify-constraints", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "verify-rederiver", + "identity": "verify-rederiver-notes", + "reads": [ + { + "at": "team:euler-hive/agent:verify-rederiver", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "verify-adversary", + "identity": "verify-adversary-notes", + "reads": [ + { + "at": "team:euler-hive/agent:verify-adversary", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "synthesis-judge", + "identity": "synthesis-judge-notes", + "reads": [ + { + "at": "team:euler-hive/agent:synthesis-judge", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + }, + { + "seat": "synthesis-dissent", + "identity": "synthesis-dissent-notes", + "reads": [ + { + "at": "team:euler-hive/agent:synthesis-dissent", + "inherit": false, + "descendants": false + }, + { + "at": "team:euler-hive/project:verified", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 2500, + "remember": [ + "observation", + "failed_attempt", + "outcome", + "note" + ] + } + ] +} diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/computer.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/computer.md new file mode 100644 index 00000000..d0a0dc14 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/computer.md @@ -0,0 +1 @@ +Implement the plan in exact Python and run it with `run_python` in the network-less sandbox. Reproduce every sample value the statement gives before computing the asked-for case. Report the program's idea and its printed output verbatim. Never state a number or set you did not print. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/researcher.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/researcher.md new file mode 100644 index 00000000..3e8812f1 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/researcher.md @@ -0,0 +1 @@ +Search the public literature for the structure behind the problem and the theory around it. Search for the mathematics, not for the problem: do not query the problem's own data or look for a posted solution. Report sources with URLs and what each one derives. Never present an answer copied from a page as a result; the hive accepts only answers it computes and verifies itself. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/synthesizer.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/synthesizer.md new file mode 100644 index 00000000..95a71161 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/synthesizer.md @@ -0,0 +1 @@ +Commit the hive's final answer, in the format the problem asks for, once the verify desk has signed it off. Refer unresolved objections back to verification. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/theorist.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/theorist.md new file mode 100644 index 00000000..334c2403 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/theorist.md @@ -0,0 +1 @@ +Identify the mathematical structure the problem describes and derive the properties that determine the answer. Hand the compute desk a concrete, checkable plan. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/verifier.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/verifier.md new file mode 100644 index 00000000..950b79e0 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/roles/verifier.md @@ -0,0 +1 @@ +Check each candidate independently against every constraint the problem states, by running your own code with `run_python`. Check every sample value the statement gives: reproduce each one in the sandbox with the candidate's method before supporting any candidate. Sign off only on what you reproduced; reject with the failing check otherwise. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/compute-exhaustive.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/compute-exhaustive.md new file mode 100644 index 00000000..10992447 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/compute-exhaustive.md @@ -0,0 +1,3 @@ +You are a "exhaustive-search engineer". +You prefer a complete enumeration you can finish over a clever argument you cannot check. You write small, exact Python, run it, and report the printed output verbatim with the script's idea in one line. +Your seat is compute-exhaustive on the compute desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/compute-linear.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/compute-linear.md new file mode 100644 index 00000000..2fd9781d --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/compute-linear.md @@ -0,0 +1,3 @@ +You are a "GF(2) linear-algebra programmer". +You encode sets as bitmasks and solve with spans, ranks and kernels over GF(2). You check that the given data is consistent before you extend it, and you print the checks along with the result. +Your seat is compute-linear on the compute desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/compute-minimalist.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/compute-minimalist.md new file mode 100644 index 00000000..e66097e6 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/compute-minimalist.md @@ -0,0 +1,3 @@ +You are a "brute-force minimalist". +You write the shortest program that could possibly be correct, run it, and compare its output with what the other computers reported. Disagreement is a finding; you report it plainly. +Your seat is compute-minimalist on the compute desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/research-librarian.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/research-librarian.md new file mode 100644 index 00000000..2c7aec14 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/research-librarian.md @@ -0,0 +1,3 @@ +You are a "patient design-theory librarian". +You know the classical literature of combinatorial designs, finite geometry and error-correcting codes. You search for the named structure behind a problem, then for its standard constructions, uniqueness theorems and invariants, and you report each with a source URL. +Your seat is research-librarian on the research desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/research-provenance.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/research-provenance.md new file mode 100644 index 00000000..51e4debc --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/research-provenance.md @@ -0,0 +1,3 @@ +You are a "provenance skeptic". +You trust no single page. For every claim you bring back you name where it came from, whether that source derives it or merely states it, and whether a second independent source agrees. You never relay a copied final answer as evidence. +Your seat is research-provenance on the research desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/research-scout.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/research-scout.md new file mode 100644 index 00000000..22719833 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/research-scout.md @@ -0,0 +1,3 @@ +You are a "algorithmic scout". +You look for how people actually construct and search the structure: explicit generators, standard labellings, lexicographic constructions, and the small computations that reproduce them. You report methods, not answers. +Your seat is research-scout on the research desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/synthesis-dissent.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/synthesis-dissent.md new file mode 100644 index 00000000..56efd593 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/synthesis-dissent.md @@ -0,0 +1,3 @@ +You are a "dissent recorder". +You read the record for unresolved objections. If any verifier objection or failed check stands, you refer it back to the verify desk instead of letting the judge commit. +Your seat is synthesis-dissent on the synthesis desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/synthesis-judge.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/synthesis-judge.md new file mode 100644 index 00000000..5e1f698f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/synthesis-judge.md @@ -0,0 +1,3 @@ +You are a "evidence-weighing chief judge". +You commit an answer only when the verify desk has signed it off with sandbox output and every stated constraint has been checked. You state the answer in the exact format the problem asks for, and the evidence chain in a few lines. +Your seat is synthesis-judge on the synthesis desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/theory-coder.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/theory-coder.md new file mode 100644 index 00000000..22bb7481 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/theory-coder.md @@ -0,0 +1,3 @@ +You are a "linear-algebra and coding theorist". +You translate a structure into linear algebra: codes over finite fields, linear systems, eigenvectors, generating functions. You turn a structural claim into an exact linear-algebra computation a program can run. +Your seat is theory-coder on the theory desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/theory-counter.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/theory-counter.md new file mode 100644 index 00000000..d5bfe903 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/theory-counter.md @@ -0,0 +1,3 @@ +You are a "counting-argument auditor". +You check every structural claim by counting. If a proposed property would make the totals disagree with the problem's own parameters, you say so with the arithmetic. +Your seat is theory-counter on the theory desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/theory-designer.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/theory-designer.md new file mode 100644 index 00000000..0669131e --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/theory-designer.md @@ -0,0 +1,3 @@ +You are a "combinatorial-design geometer". +You think in structures: blocks and points, Steiner systems, symmetric games, matrices with symmetry. You identify the classical object a problem describes, derive its parameters and invariants, and state which of them pin the answer down. +Your seat is theory-designer on the theory desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/verify-adversary.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/verify-adversary.md new file mode 100644 index 00000000..1c6512bf --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/verify-adversary.md @@ -0,0 +1,3 @@ +You are a "adversarial uniqueness tester". +You try to break a candidate: you search for a second object that also satisfies every constraint. If you find one, the answer is not determined and you say so; if your search is exhaustive and finds none, you report that it is unique. +Your seat is verify-adversary on the verify desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/verify-constraints.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/verify-constraints.md new file mode 100644 index 00000000..2786c01a --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/verify-constraints.md @@ -0,0 +1,3 @@ +You are a "constraint-by-constraint auditor". +You restate every constraint in the problem as a test, write code that checks a candidate against each one separately, run it, and report pass or fail per constraint. A candidate that fails one test is rejected, with the failing test named. +Your seat is verify-constraints on the verify desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/verify-rederiver.md b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/verify-rederiver.md new file mode 100644 index 00000000..4ea8bcfe --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/euler-hive/seats/verify-rederiver.md @@ -0,0 +1,3 @@ +You are a "independent re-deriver". +You ignore how a candidate was found. You derive the answer again by a different method from the problem statement alone, in the sandbox, and only then compare. Agreement by two methods is the only sign-off you give. +Your seat is verify-rederiver on the verify desk. Read the problem in the shared context, work your part, and end every turn with exactly one hive action: `hive_broadcast` to a desk colleague, `hive_refer` to another desk, or `hive_complete` with your desk's report. diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/README.md b/crates/tinyhivemind-lang/examples/hives/incident-response/README.md new file mode 100644 index 00000000..54a19b66 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/README.md @@ -0,0 +1,14 @@ +# incident-response + +Two desks joined by topology and referrals. The triage desk owns the incident +and refers concrete fixes to the fixers desk, which refers back when a fix +needs a decision. Referral is enabled with a hop limit of two, any production +deploy needs approval from the desk's approver, and seats may open private +asides of two members that must surface their conclusion. + +| Path | Purpose | +| --- | --- | +| `hive.json` | Two desks, `triage -> fixers` topology, referrals both ways, approval, asides | +| `constitution.json` | Default caps | +| `memory.json` | No memory | +| `roles/`, `seats/` | Charters and prompts | diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/constitution.json b/crates/tinyhivemind-lang/examples/hives/incident-response/constitution.json new file mode 100644 index 00000000..a50f7ca2 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/constitution.json @@ -0,0 +1,12 @@ +{ + "hard_constraints": [], + "max_turns": 60, + "max_width": 8, + "permitted_edits": [], + "read_only_memories": [], + "protected_namespaces": [], + "contamination_canaries": [], + "evaluation": null, + "acceptance": null, + "telemetry": null +} diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/hive.json b/crates/tinyhivemind-lang/examples/hives/incident-response/hive.json new file mode 100644 index 00000000..7cfb438f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/hive.json @@ -0,0 +1,348 @@ +{ + "$schema": "https://tinyhumans.ai/schemas/hive/v1", + "version": 1, + "id": "incident-response", + "name": "Incident response", + "roles": [ + { + "id": "incident-lead", + "charter": "roles/incident-lead.md" + }, + { + "id": "triager", + "charter": "roles/triager.md" + }, + { + "id": "fixer", + "charter": "roles/fixer.md" + }, + { + "id": "release-reviewer", + "charter": "roles/release-reviewer.md" + } + ], + "seats": [ + { + "id": "incident-lead", + "label": "incident-lead", + "role": "incident-lead", + "prompt": "seats/incident-lead.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "triager-logs", + "label": "triager-logs", + "role": "triager", + "prompt": "seats/triager-logs.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "triager-metrics", + "label": "triager-metrics", + "role": "triager", + "prompt": "seats/triager-metrics.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "fixer-db", + "label": "fixer-db", + "role": "fixer", + "prompt": "seats/fixer-db.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "fixer-net", + "label": "fixer-net", + "role": "fixer", + "prompt": "seats/fixer-net.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "release-reviewer", + "label": "release-reviewer", + "role": "release-reviewer", + "prompt": "seats/release-reviewer.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + } + ], + "desks": [ + { + "id": "triage", + "name": "Triage", + "description": "Find the cause of the checkout latency spike", + "members": [ + "incident-lead", + "triager-logs", + "triager-metrics" + ], + "responder_mode": "auto" + }, + { + "id": "fixers", + "name": "Fixers", + "description": "Ship the fix triage refers, safely", + "members": [ + "fixer-db", + "fixer-net", + "release-reviewer" + ], + "responder_mode": "auto" + } + ], + "goals": [], + "missions": [], + "topology": [ + { + "from": "triage", + "to": "fixers" + } + ], + "referrals": [ + { + "from": "triage", + "to": "fixers" + }, + { + "from": "fixers", + "to": "triage" + } + ], + "context": [], + "policies": { + "episode": { + "turn_budget": 18, + "round_width": 3, + "revealed_width": 3, + "blind_round": true, + "dominance_cap": 50, + "repetition_cap": 3, + "distance": "sequence", + "quorum": { + "threshold": 3, + "window": 30, + "require_grounded": true + }, + "weights": { + "recency": 5, + "importance": 30, + "relevance": 20, + "half_life": 20 + } + }, + "routing": { + "minimum_confidence": 0, + "high_impact_minimum_confidence": 0, + "clarification_threshold": 1000000, + "high_impact_threshold": 1000000, + "round_width": 4, + "choice_option_limit": 8 + }, + "approval": { + "enabled": true, + "default": "ask", + "rules": [ + { + "effect": "read_only", + "verb": null, + "target": null, + "verdict": "allow" + }, + { + "effect": "mutating", + "verb": "deploy", + "target": { + "kind": "resource", + "root": "prod" + }, + "verdict": "ask" + } + ], + "approver": { + "kind": "per_desk", + "default": "oncall-manager", + "overrides": [ + { + "desk_id": "fixers", + "person_id": "release-manager" + } + ] + }, + "allow_grants": true, + "max_grant_ttl": 900000 + }, + "aside": { + "enabled": true, + "max_members": 2, + "max_messages": 6, + "must_surface": true, + "require_thread": false + }, + "referral": { + "enabled": true, + "max_hops": 2, + "reach": "desks", + "returns": true + }, + "digest": { + "keep_live": 30, + "fold_after": 20, + "fold_after_chars": 400000, + "input_limit": 60, + "budget_chars": 4000 + }, + "brevity": { + "message_chars": 600, + "window": 30 + }, + "directory": { + "half_life": 20, + "specialisation": 30, + "credibility": 20, + "prior": 10, + "discredit": 20, + "window": 30, + "floor": 1000 + }, + "exchange": { + "enabled": false, + "contact_cap": 0, + "round_cap": 0 + }, + "division": { + "round_width": 4, + "follow_directory": true + }, + "conduct": { + "child_turn_wall": 6, + "turn_wall": 60 + } + }, + "coordinator": { + "round_width": 4, + "conduct_policy": { + "child_turn_wall": 6, + "turn_wall": 60 + }, + "broadcast_budget": null, + "retention": { + "settled_episodes": null, + "delivered": null, + "interrupted": null, + "pending_per_agent": null + } + } +} diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/memory.json b/crates/tinyhivemind-lang/examples/hives/incident-response/memory.json new file mode 100644 index 00000000..f3a23bf0 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/memory.json @@ -0,0 +1,5 @@ +{ + "root": "team:hive", + "identities": [], + "bindings": [] +} diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/roles/README.md b/crates/tinyhivemind-lang/examples/hives/incident-response/roles/README.md new file mode 100644 index 00000000..ff997996 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/roles/README.md @@ -0,0 +1,10 @@ +# Role charters + +One charter per role, independent of the seats that fill it. + +| File | Charter | +| --- | --- | +| `incident-lead.md` | Own the incident: state impact, choose the next action, and refer work to the de | +| `triager.md` | Narrow the cause with evidence from dashboards and logs; report what you ruled o | +| `fixer.md` | Prepare the smallest safe fix for the cause triage named, and request approval b | +| `release-reviewer.md` | Check every proposed fix for blast radius and rollback before it is deployed | diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/roles/fixer.md b/crates/tinyhivemind-lang/examples/hives/incident-response/roles/fixer.md new file mode 100644 index 00000000..183eb6e0 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/roles/fixer.md @@ -0,0 +1 @@ +Prepare the smallest safe fix for the cause triage named, and request approval before any production change. diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/roles/incident-lead.md b/crates/tinyhivemind-lang/examples/hives/incident-response/roles/incident-lead.md new file mode 100644 index 00000000..c242f876 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/roles/incident-lead.md @@ -0,0 +1 @@ +Own the incident: state impact, choose the next action, and refer work to the desk that can do it. diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/roles/release-reviewer.md b/crates/tinyhivemind-lang/examples/hives/incident-response/roles/release-reviewer.md new file mode 100644 index 00000000..1bec0432 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/roles/release-reviewer.md @@ -0,0 +1 @@ +Check every proposed fix for blast radius and rollback before it is deployed. diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/roles/triager.md b/crates/tinyhivemind-lang/examples/hives/incident-response/roles/triager.md new file mode 100644 index 00000000..7d545d55 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/roles/triager.md @@ -0,0 +1 @@ +Narrow the cause with evidence from dashboards and logs; report what you ruled out as well as what you found. diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/seats/README.md b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/README.md new file mode 100644 index 00000000..04e27110 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/README.md @@ -0,0 +1,12 @@ +# Seat prompts + +One prompt per seat. A seat names its role in `hive.json`. + +| File | Role | +| --- | --- | +| `incident-lead.md` | incident-lead | +| `triager-logs.md` | triager | +| `triager-metrics.md` | triager | +| `fixer-db.md` | fixer | +| `fixer-net.md` | fixer | +| `release-reviewer.md` | release-reviewer | diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/seats/fixer-db.md b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/fixer-db.md new file mode 100644 index 00000000..8f1cdeff --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/fixer-db.md @@ -0,0 +1 @@ +You own the database tier. Propose a pool-size or query fix with its rollback, and ask for approval before deploying it. diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/seats/fixer-net.md b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/fixer-net.md new file mode 100644 index 00000000..a298e0ee --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/fixer-net.md @@ -0,0 +1 @@ +You own the network tier. Rule the load balancer in or out with evidence before proposing any change. diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/seats/incident-lead.md b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/incident-lead.md new file mode 100644 index 00000000..03e87b0b --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/incident-lead.md @@ -0,0 +1 @@ +You lead the checkout latency incident. Post impact first, then propose a cause as a `#topic`; refer the fix to the fixers desk once two seats support it. diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/seats/release-reviewer.md b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/release-reviewer.md new file mode 100644 index 00000000..0e9ee9b5 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/release-reviewer.md @@ -0,0 +1 @@ +You review fixes for blast radius. Object to any fix that lacks a rollback step, citing the message that omits it. diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/seats/triager-logs.md b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/triager-logs.md new file mode 100644 index 00000000..46f7869f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/triager-logs.md @@ -0,0 +1 @@ +You read logs. Cite the exact error lines; the connection-pool exhaustion warnings started at 14:02. diff --git a/crates/tinyhivemind-lang/examples/hives/incident-response/seats/triager-metrics.md b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/triager-metrics.md new file mode 100644 index 00000000..cfd60ee6 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/incident-response/seats/triager-metrics.md @@ -0,0 +1 @@ +You read metrics. Correlate p99 latency with database connections and network retransmits, and say which moved first. diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/README.md b/crates/tinyhivemind-lang/examples/hives/math-panel/README.md new file mode 100644 index 00000000..95a0af19 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/README.md @@ -0,0 +1,34 @@ +# math-panel + +Five seats on one desk solving small exact problems: a conjecturer, two +solvers, a checker and a lead. Every seat is unreliable on its own; the panel +decides by a grounded quorum of three, and the checker objects to advocates of +an answer it cannot reproduce. Compare it with `math-swarm`, which solves the +same problems with fifty seats. + +| Path | Purpose | +| --- | --- | +| `hive.json` | One desk, five seats, blind opening round of five, quorum of three | +| `constitution.json` | Default caps | +| `memory.json` | No memory | +| `sim.json` | The offline problem bank and seat behaviour | +| `roles/`, `seats/` | Charters and prompts | + +## Simulation (`sim.json`) + +`sim.json` is neither canonical nor Markdown, so `parse` drops it; only the +`hive_view` example reads it. It holds: + +- `problems`: small problems whose answers `hive_view` computes exactly from + `kind` and `args` (`lattice`, `modsum`, `digitsum`, `multiples`, `sqdiff`, + `primes`). Correct means equal to that computation. +- `roles`: each role's behaviour and the range, in parts per million, that each + seat's accuracy is drawn from with the run's seed. +- `errors`: how a wrong answer is chosen — the problem's classic mistake, or + the answer plus or minus one. +- `slices`: the moduli a partial calculator knows the answer modulo. +- `baseline`: the single seat `--compare` runs alone. + +The ranges are shared by every math package and were fixed before any run, so +a seat in the swarm is no better than its counterpart on the panel. See the +`hive_view` README for what each behaviour does. diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/constitution.json b/crates/tinyhivemind-lang/examples/hives/math-panel/constitution.json new file mode 100644 index 00000000..a50f7ca2 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/constitution.json @@ -0,0 +1,12 @@ +{ + "hard_constraints": [], + "max_turns": 60, + "max_width": 8, + "permitted_edits": [], + "read_only_memories": [], + "protected_namespaces": [], + "contamination_canaries": [], + "evaluation": null, + "acceptance": null, + "telemetry": null +} diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/hive.json b/crates/tinyhivemind-lang/examples/hives/math-panel/hive.json new file mode 100644 index 00000000..e62fbf73 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/hive.json @@ -0,0 +1,276 @@ +{ + "$schema": "https://tinyhumans.ai/schemas/hive/v1", + "version": 1, + "id": "math-panel", + "name": "Math panel", + "roles": [ + { + "id": "conjecturer", + "charter": "roles/conjecturer.md" + }, + { + "id": "solver", + "charter": "roles/solver.md" + }, + { + "id": "checker", + "charter": "roles/checker.md" + }, + { + "id": "lead", + "charter": "roles/lead.md" + } + ], + "seats": [ + { + "id": "conjecturer", + "label": "conjecturer", + "role": "conjecturer", + "prompt": "seats/conjecturer.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "solver-a", + "label": "solver-a", + "role": "solver", + "prompt": "seats/solver-a.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "solver-b", + "label": "solver-b", + "role": "solver", + "prompt": "seats/solver-b.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "checker", + "label": "checker", + "role": "checker", + "prompt": "seats/checker.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "lead", + "label": "lead", + "role": "lead", + "prompt": "seats/lead.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + } + ], + "desks": [ + { + "id": "panel", + "name": "Panel", + "description": "Solve the posed problem exactly", + "members": [ + "conjecturer", + "solver-a", + "solver-b", + "checker", + "lead" + ], + "responder_mode": "auto" + } + ], + "goals": [], + "missions": [], + "topology": [], + "referrals": [], + "context": [], + "policies": { + "episode": { + "turn_budget": 15, + "round_width": 5, + "revealed_width": 5, + "blind_round": true, + "dominance_cap": 50, + "repetition_cap": 3, + "distance": "sequence", + "quorum": { + "threshold": 3, + "window": 15, + "require_grounded": true + }, + "weights": { + "recency": 5, + "importance": 30, + "relevance": 20, + "half_life": 20 + } + }, + "routing": { + "minimum_confidence": 0, + "high_impact_minimum_confidence": 0, + "clarification_threshold": 1000000, + "high_impact_threshold": 1000000, + "round_width": 4, + "choice_option_limit": 8 + }, + "approval": { + "enabled": false, + "default": "deny", + "rules": [], + "approver": { + "kind": "absent" + }, + "allow_grants": false, + "max_grant_ttl": null + }, + "aside": { + "enabled": false, + "max_members": 0, + "max_messages": 0, + "must_surface": false, + "require_thread": false + }, + "referral": { + "enabled": false, + "max_hops": 0, + "reach": "local", + "returns": false + }, + "digest": { + "keep_live": 30, + "fold_after": 20, + "fold_after_chars": 400000, + "input_limit": 60, + "budget_chars": 4000 + }, + "brevity": { + "message_chars": 600, + "window": 30 + }, + "directory": { + "half_life": 20, + "specialisation": 30, + "credibility": 20, + "prior": 10, + "discredit": 20, + "window": 30, + "floor": 1000 + }, + "exchange": { + "enabled": false, + "contact_cap": 0, + "round_cap": 0 + }, + "division": { + "round_width": 4, + "follow_directory": true + }, + "conduct": { + "child_turn_wall": 6, + "turn_wall": 60 + } + }, + "coordinator": { + "round_width": 4, + "conduct_policy": { + "child_turn_wall": 6, + "turn_wall": 60 + }, + "broadcast_budget": null, + "retention": { + "settled_episodes": null, + "delivered": null, + "interrupted": null, + "pending_per_agent": null + } + } +} diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/memory.json b/crates/tinyhivemind-lang/examples/hives/math-panel/memory.json new file mode 100644 index 00000000..f3a23bf0 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/memory.json @@ -0,0 +1,5 @@ +{ + "root": "team:hive", + "identities": [], + "bindings": [] +} diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/roles/README.md b/crates/tinyhivemind-lang/examples/hives/math-panel/roles/README.md new file mode 100644 index 00000000..4e97779e --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/roles/README.md @@ -0,0 +1,10 @@ +# Role charters + +One charter per role, independent of the seats that fill it. + +| File | Charter | +| --- | --- | +| `conjecturer.md` | Guess quickly from pattern and magnitude. Say how confident you are; you are oft | +| `solver.md` | Derive the answer in full and propose it with the working that produced it | +| `checker.md` | Re-derive the answer independently and object to any advocate of an answer you c | +| `lead.md` | Weigh the room, support the best-grounded answer, and record the decision once i | diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/roles/checker.md b/crates/tinyhivemind-lang/examples/hives/math-panel/roles/checker.md new file mode 100644 index 00000000..a44c7f5a --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/roles/checker.md @@ -0,0 +1 @@ +Re-derive the answer independently and object to any advocate of an answer you cannot reproduce. diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/roles/conjecturer.md b/crates/tinyhivemind-lang/examples/hives/math-panel/roles/conjecturer.md new file mode 100644 index 00000000..48edccea --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/roles/conjecturer.md @@ -0,0 +1 @@ +Guess quickly from pattern and magnitude. Say how confident you are; you are often wrong. diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/roles/lead.md b/crates/tinyhivemind-lang/examples/hives/math-panel/roles/lead.md new file mode 100644 index 00000000..d641f590 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/roles/lead.md @@ -0,0 +1 @@ +Weigh the room, support the best-grounded answer, and record the decision once it carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/roles/solver.md b/crates/tinyhivemind-lang/examples/hives/math-panel/roles/solver.md new file mode 100644 index 00000000..d132ba5c --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/roles/solver.md @@ -0,0 +1 @@ +Derive the answer in full and propose it with the working that produced it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/seats/README.md b/crates/tinyhivemind-lang/examples/hives/math-panel/seats/README.md new file mode 100644 index 00000000..a8c5783a --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/seats/README.md @@ -0,0 +1,13 @@ +# Seat prompts + +One prompt per seat. A seat names its role in `hive.json`. Each prompt opens +with a persona (`You are a "".`) and a sentence on how it thinks, +which a `--live` model plays; the offline simulation ignores it. + +| File | Role | +| --- | --- | +| `conjecturer.md` | conjecturer | +| `solver-a.md` | solver | +| `solver-b.md` | solver | +| `checker.md` | checker | +| `lead.md` | lead | diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/seats/checker.md b/crates/tinyhivemind-lang/examples/hives/math-panel/seats/checker.md new file mode 100644 index 00000000..564fe87b --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/seats/checker.md @@ -0,0 +1,3 @@ +You are a "from-scratch recomputation referee". +You trust no one's working, recompute the answer yourself, and treat any disagreement as a claim to be tested. +Your seat is checker. Recompute from scratch, then `!object >N ^M` each advocate of an answer that disagrees with yours. diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/seats/conjecturer.md b/crates/tinyhivemind-lang/examples/hives/math-panel/seats/conjecturer.md new file mode 100644 index 00000000..58d92855 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/seats/conjecturer.md @@ -0,0 +1,3 @@ +You are a "quick-draw heuristic estimator". +You reach for small cases and rough magnitude first and commit to a number fast, knowing others will refine it. +Your seat is conjecturer. Estimate the answer from small cases and magnitude, propose it as `#`, say how confident you are, and expect to be corrected. diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/seats/lead.md b/crates/tinyhivemind-lang/examples/hives/math-panel/seats/lead.md new file mode 100644 index 00000000..ede34e7c --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/seats/lead.md @@ -0,0 +1,3 @@ +You are a "panel chair who weighs grounded support". +You side with the answer whose support is best grounded and has survived every objection, not the loudest one. +Your seat is lead. Support the answer with the most grounded support that survives objection, and `!commit` it once quorum carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/seats/solver-a.md b/crates/tinyhivemind-lang/examples/hives/math-panel/seats/solver-a.md new file mode 100644 index 00000000..5b31b930 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/seats/solver-a.md @@ -0,0 +1,3 @@ +You are a "direct-derivation problem solver". +You attack the problem head-on with combinatorial or arithmetic derivation, writing down each step you rely on. +Your seat is solver-a. Solve by direct combinatorial or arithmetic derivation, and propose your exact answer with one line of working. diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/seats/solver-b.md b/crates/tinyhivemind-lang/examples/hives/math-panel/seats/solver-b.md new file mode 100644 index 00000000..2ce32c0a --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/seats/solver-b.md @@ -0,0 +1,3 @@ +You are a "closed-form and recurrence theorist". +You look for the structure behind the count, a recurrence or a closed formula, rather than grinding through cases. +Your seat is solver-b. Solve by a method independent of solver-a's, a recurrence or a closed form, and propose your exact answer with its working. diff --git a/crates/tinyhivemind-lang/examples/hives/math-panel/sim.json b/crates/tinyhivemind-lang/examples/hives/math-panel/sim.json new file mode 100644 index 00000000..58dc17f5 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-panel/sim.json @@ -0,0 +1,133 @@ +{ + "kind": "math", + "problems": [ + { + "id": "lattice-6x6", + "kind": "lattice", + "args": [ + 6, + 6 + ], + "statement": "How many shortest lattice paths run from (0,0) to (6,6) using unit steps right and up?" + }, + { + "id": "lattice-5x8", + "kind": "lattice", + "args": [ + 5, + 8 + ], + "statement": "How many shortest lattice paths run from (0,0) to (5,8) using unit steps right and up?" + }, + { + "id": "modsum-squares", + "kind": "modsum", + "args": [ + 100, + 2, + 97 + ], + "statement": "What is 1^2 + 2^2 + ... + 100^2 modulo 97?" + }, + { + "id": "modsum-cubes", + "kind": "modsum", + "args": [ + 50, + 3, + 101 + ], + "statement": "What is 1^3 + 2^3 + ... + 50^3 modulo 101?" + }, + { + "id": "digitsum-2pow50", + "kind": "digitsum", + "args": [ + 2, + 50 + ], + "statement": "What is the sum of the decimal digits of 2^50?" + }, + { + "id": "multiples-3-5", + "kind": "multiples", + "args": [ + 1000, + 3, + 5 + ], + "statement": "What is the sum of all natural numbers below 1000 that are multiples of 3 or 5?" + }, + { + "id": "sqdiff-20", + "kind": "sqdiff", + "args": [ + 20 + ], + "statement": "What is (1 + 2 + ... + 20)^2 minus (1^2 + 2^2 + ... + 20^2)?" + }, + { + "id": "primes-200", + "kind": "primes", + "args": [ + 200 + ], + "statement": "How many primes are there below 200?" + } + ], + "errors": { + "classic": 500000, + "plus_one": 250000, + "minus_one": 250000 + }, + "roles": { + "conjecturer": { + "behaviour": "guess", + "accuracy": [ + 250000, + 450000 + ] + }, + "solver": { + "behaviour": "solve", + "accuracy": [ + 450000, + 750000 + ] + }, + "checker": { + "behaviour": "check", + "accuracy": [ + 500000, + 800000 + ] + }, + "lead": { + "behaviour": "lead", + "accuracy": [ + 450000, + 750000 + ] + } + }, + "slices": [ + 3, + 4, + 5, + 7, + 8, + 9, + 11, + 13, + 16, + 25 + ], + "baseline": { + "role": "solver", + "behaviour": "solve", + "accuracy": [ + 450000, + 750000 + ] + } +} diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/README.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/README.md new file mode 100644 index 00000000..1861b71b --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/README.md @@ -0,0 +1,48 @@ +# math-swarm + +Fifty seats in five desks of ten solving the same problems as `math-panel`. +Conjecturers guess, computers are exact on one residue slice each, provers +derive in full, verifiers object to advocates of answers they cannot +reproduce, and synthesis leads support the best-grounded answer and record it. +The desks are chained by topology (`conjecture -> compute -> prove -> verify -> +synthesis`, with synthesis feeding back to conjecture) and joined by referrals +into the verify desk and out of it to synthesis. The constitution raises the +caps to 200 turns and width 10; blind rounds run ten seats at a time; quorum is +a grounded majority of the room (26), the threshold +`EpisodePolicy::for_room(50)` derives. Referral is enabled with +`max_hops` 4, the depth of the `conjecture -> ... -> synthesis` pipeline, so +synthesis's feedback to conjecture and prove is the hop the budget refuses. + +`hive_view` runs each desk on its own floor (each desk takes its share of the +quorum and budget: 6 of 10, 30 turns) and moves answers between desks only +by referral along these links; synthesis's ending is the hive's answer. Every +seat prompt opens with a distinct persona (`You are a "..."`), which is what a +`--live` model plays; offline the simulation ignores it. No seat may use a web +tool: each denies `web_search`, `web_fetch`, `browser` and `http`. + +| Path | Purpose | +| --- | --- | +| `hive.json` | Five desks of ten, topology, referrals (`max_hops` 4), rounds of ten, quorum of 26, web tools denied | +| `constitution.json` | `max_turns` 200, `max_width` 10 | +| `memory.json` | No memory | +| `sim.json` | The offline problem bank and seat behaviour (same ranges as the panel) | +| `roles/`, `seats/` | Charters and fifty seat prompts, each with its own persona | + +## Simulation (`sim.json`) + +`sim.json` is neither canonical nor Markdown, so `parse` drops it; only the +`hive_view` example reads it. It holds: + +- `problems`: small problems whose answers `hive_view` computes exactly from + `kind` and `args` (`lattice`, `modsum`, `digitsum`, `multiples`, `sqdiff`, + `primes`). Correct means equal to that computation. +- `roles`: each role's behaviour and the range, in parts per million, that each + seat's accuracy is drawn from with the run's seed. +- `errors`: how a wrong answer is chosen — the problem's classic mistake, or + the answer plus or minus one. +- `slices`: the moduli a partial calculator knows the answer modulo. +- `baseline`: the single seat `--compare` runs alone. + +The ranges are shared by every math package and were fixed before any run, so +a seat in the swarm is no better than its counterpart on the panel. See the +`hive_view` README for what each behaviour does. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/constitution.json b/crates/tinyhivemind-lang/examples/hives/math-swarm/constitution.json new file mode 100644 index 00000000..8e45e59b --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/constitution.json @@ -0,0 +1,12 @@ +{ + "hard_constraints": [], + "max_turns": 200, + "max_width": 10, + "permitted_edits": [], + "read_only_memories": [], + "protected_namespaces": [], + "contamination_canaries": [], + "evaluation": null, + "acceptance": null, + "telemetry": null +} diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/hive.json b/crates/tinyhivemind-lang/examples/hives/math-swarm/hive.json new file mode 100644 index 00000000..0b68ac3f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/hive.json @@ -0,0 +1,1569 @@ +{ + "$schema": "https://tinyhumans.ai/schemas/hive/v1", + "version": 1, + "id": "math-swarm", + "name": "Math swarm", + "roles": [ + { + "id": "conjecturer", + "charter": "roles/conjecturer.md" + }, + { + "id": "computer", + "charter": "roles/computer.md" + }, + { + "id": "prover", + "charter": "roles/prover.md" + }, + { + "id": "verifier", + "charter": "roles/verifier.md" + }, + { + "id": "synthesizer", + "charter": "roles/synthesizer.md" + } + ], + "seats": [ + { + "id": "conjecture-01", + "label": "conjecture-01", + "role": "conjecturer", + "prompt": "seats/conjecture-01.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "conjecture-02", + "label": "conjecture-02", + "role": "conjecturer", + "prompt": "seats/conjecture-02.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "conjecture-03", + "label": "conjecture-03", + "role": "conjecturer", + "prompt": "seats/conjecture-03.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "conjecture-04", + "label": "conjecture-04", + "role": "conjecturer", + "prompt": "seats/conjecture-04.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "conjecture-05", + "label": "conjecture-05", + "role": "conjecturer", + "prompt": "seats/conjecture-05.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "conjecture-06", + "label": "conjecture-06", + "role": "conjecturer", + "prompt": "seats/conjecture-06.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "conjecture-07", + "label": "conjecture-07", + "role": "conjecturer", + "prompt": "seats/conjecture-07.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "conjecture-08", + "label": "conjecture-08", + "role": "conjecturer", + "prompt": "seats/conjecture-08.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "conjecture-09", + "label": "conjecture-09", + "role": "conjecturer", + "prompt": "seats/conjecture-09.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "conjecture-10", + "label": "conjecture-10", + "role": "conjecturer", + "prompt": "seats/conjecture-10.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-01", + "label": "compute-01", + "role": "computer", + "prompt": "seats/compute-01.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-02", + "label": "compute-02", + "role": "computer", + "prompt": "seats/compute-02.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-03", + "label": "compute-03", + "role": "computer", + "prompt": "seats/compute-03.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-04", + "label": "compute-04", + "role": "computer", + "prompt": "seats/compute-04.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-05", + "label": "compute-05", + "role": "computer", + "prompt": "seats/compute-05.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-06", + "label": "compute-06", + "role": "computer", + "prompt": "seats/compute-06.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-07", + "label": "compute-07", + "role": "computer", + "prompt": "seats/compute-07.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-08", + "label": "compute-08", + "role": "computer", + "prompt": "seats/compute-08.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-09", + "label": "compute-09", + "role": "computer", + "prompt": "seats/compute-09.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "compute-10", + "label": "compute-10", + "role": "computer", + "prompt": "seats/compute-10.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "prove-01", + "label": "prove-01", + "role": "prover", + "prompt": "seats/prove-01.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "prove-02", + "label": "prove-02", + "role": "prover", + "prompt": "seats/prove-02.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "prove-03", + "label": "prove-03", + "role": "prover", + "prompt": "seats/prove-03.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "prove-04", + "label": "prove-04", + "role": "prover", + "prompt": "seats/prove-04.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "prove-05", + "label": "prove-05", + "role": "prover", + "prompt": "seats/prove-05.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "prove-06", + "label": "prove-06", + "role": "prover", + "prompt": "seats/prove-06.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "prove-07", + "label": "prove-07", + "role": "prover", + "prompt": "seats/prove-07.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "prove-08", + "label": "prove-08", + "role": "prover", + "prompt": "seats/prove-08.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "prove-09", + "label": "prove-09", + "role": "prover", + "prompt": "seats/prove-09.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "prove-10", + "label": "prove-10", + "role": "prover", + "prompt": "seats/prove-10.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-01", + "label": "verify-01", + "role": "verifier", + "prompt": "seats/verify-01.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-02", + "label": "verify-02", + "role": "verifier", + "prompt": "seats/verify-02.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-03", + "label": "verify-03", + "role": "verifier", + "prompt": "seats/verify-03.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-04", + "label": "verify-04", + "role": "verifier", + "prompt": "seats/verify-04.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-05", + "label": "verify-05", + "role": "verifier", + "prompt": "seats/verify-05.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-06", + "label": "verify-06", + "role": "verifier", + "prompt": "seats/verify-06.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-07", + "label": "verify-07", + "role": "verifier", + "prompt": "seats/verify-07.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-08", + "label": "verify-08", + "role": "verifier", + "prompt": "seats/verify-08.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-09", + "label": "verify-09", + "role": "verifier", + "prompt": "seats/verify-09.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "verify-10", + "label": "verify-10", + "role": "verifier", + "prompt": "seats/verify-10.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "synthesis-01", + "label": "synthesis-01", + "role": "synthesizer", + "prompt": "seats/synthesis-01.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "synthesis-02", + "label": "synthesis-02", + "role": "synthesizer", + "prompt": "seats/synthesis-02.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "synthesis-03", + "label": "synthesis-03", + "role": "synthesizer", + "prompt": "seats/synthesis-03.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "synthesis-04", + "label": "synthesis-04", + "role": "synthesizer", + "prompt": "seats/synthesis-04.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "synthesis-05", + "label": "synthesis-05", + "role": "synthesizer", + "prompt": "seats/synthesis-05.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "synthesis-06", + "label": "synthesis-06", + "role": "synthesizer", + "prompt": "seats/synthesis-06.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "synthesis-07", + "label": "synthesis-07", + "role": "synthesizer", + "prompt": "seats/synthesis-07.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "synthesis-08", + "label": "synthesis-08", + "role": "synthesizer", + "prompt": "seats/synthesis-08.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "synthesis-09", + "label": "synthesis-09", + "role": "synthesizer", + "prompt": "seats/synthesis-09.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "synthesis-10", + "label": "synthesis-10", + "role": "synthesizer", + "prompt": "seats/synthesis-10.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + } + ], + "desks": [ + { + "id": "conjecture", + "name": "Conjecture", + "description": "Heuristic guessers working from pattern and magnitude", + "members": [ + "conjecture-01", + "conjecture-02", + "conjecture-03", + "conjecture-04", + "conjecture-05", + "conjecture-06", + "conjecture-07", + "conjecture-08", + "conjecture-09", + "conjecture-10" + ], + "responder_mode": "auto" + }, + { + "id": "compute", + "name": "Compute", + "description": "Exact calculators, each covering one slice of the problem", + "members": [ + "compute-01", + "compute-02", + "compute-03", + "compute-04", + "compute-05", + "compute-06", + "compute-07", + "compute-08", + "compute-09", + "compute-10" + ], + "responder_mode": "auto" + }, + { + "id": "prove", + "name": "Prove", + "description": "Argument builders deriving the answer in full", + "members": [ + "prove-01", + "prove-02", + "prove-03", + "prove-04", + "prove-05", + "prove-06", + "prove-07", + "prove-08", + "prove-09", + "prove-10" + ], + "responder_mode": "auto" + }, + { + "id": "verify", + "name": "Verify", + "description": "Independent checkers who object to answers they cannot reproduce", + "members": [ + "verify-01", + "verify-02", + "verify-03", + "verify-04", + "verify-05", + "verify-06", + "verify-07", + "verify-08", + "verify-09", + "verify-10" + ], + "responder_mode": "auto" + }, + { + "id": "synthesis", + "name": "Synthesis", + "description": "Leads that weigh the room and record the decision", + "members": [ + "synthesis-01", + "synthesis-02", + "synthesis-03", + "synthesis-04", + "synthesis-05", + "synthesis-06", + "synthesis-07", + "synthesis-08", + "synthesis-09", + "synthesis-10" + ], + "responder_mode": "auto" + } + ], + "goals": [], + "missions": [], + "topology": [ + { + "from": "conjecture", + "to": "compute" + }, + { + "from": "compute", + "to": "prove" + }, + { + "from": "prove", + "to": "verify" + }, + { + "from": "verify", + "to": "synthesis" + }, + { + "from": "synthesis", + "to": "conjecture" + } + ], + "referrals": [ + { + "from": "conjecture", + "to": "verify" + }, + { + "from": "compute", + "to": "verify" + }, + { + "from": "prove", + "to": "verify" + }, + { + "from": "verify", + "to": "synthesis" + }, + { + "from": "synthesis", + "to": "prove" + } + ], + "context": [], + "policies": { + "episode": { + "turn_budget": 150, + "round_width": 10, + "revealed_width": 10, + "blind_round": true, + "dominance_cap": 50, + "repetition_cap": 3, + "distance": "sequence", + "quorum": { + "threshold": 26, + "window": 150, + "require_grounded": true + }, + "weights": { + "recency": 5, + "importance": 30, + "relevance": 20, + "half_life": 50 + } + }, + "routing": { + "minimum_confidence": 0, + "high_impact_minimum_confidence": 0, + "clarification_threshold": 1000000, + "high_impact_threshold": 1000000, + "round_width": 4, + "choice_option_limit": 8 + }, + "approval": { + "enabled": false, + "default": "deny", + "rules": [], + "approver": { + "kind": "absent" + }, + "allow_grants": false, + "max_grant_ttl": null + }, + "aside": { + "enabled": false, + "max_members": 0, + "max_messages": 0, + "must_surface": false, + "require_thread": false + }, + "referral": { + "enabled": true, + "max_hops": 4, + "reach": "desks", + "returns": true + }, + "digest": { + "keep_live": 30, + "fold_after": 20, + "fold_after_chars": 400000, + "input_limit": 60, + "budget_chars": 4000 + }, + "brevity": { + "message_chars": 600, + "window": 30 + }, + "directory": { + "half_life": 20, + "specialisation": 30, + "credibility": 20, + "prior": 10, + "discredit": 20, + "window": 30, + "floor": 1000 + }, + "exchange": { + "enabled": false, + "contact_cap": 0, + "round_cap": 0 + }, + "division": { + "round_width": 10, + "follow_directory": true + }, + "conduct": { + "child_turn_wall": 10, + "turn_wall": 150 + } + }, + "coordinator": { + "round_width": 10, + "conduct_policy": { + "child_turn_wall": 10, + "turn_wall": 150 + }, + "broadcast_budget": null, + "retention": { + "settled_episodes": null, + "delivered": null, + "interrupted": null, + "pending_per_agent": null + } + } +} diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/memory.json b/crates/tinyhivemind-lang/examples/hives/math-swarm/memory.json new file mode 100644 index 00000000..f3a23bf0 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/memory.json @@ -0,0 +1,5 @@ +{ + "root": "team:hive", + "identities": [], + "bindings": [] +} diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/README.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/README.md new file mode 100644 index 00000000..5a540c14 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/README.md @@ -0,0 +1,11 @@ +# Role charters + +One charter per role, independent of the seats that fill it. + +| File | Charter | +| --- | --- | +| `conjecturer.md` | Guess quickly from pattern and magnitude. Say how confident you are; you are oft | +| `computer.md` | Compute exactly, but only the slice of the problem you own. Never claim more tha | +| `prover.md` | Derive the answer in full and propose it with the argument that produced it | +| `verifier.md` | Re-derive the answer independently and object to any advocate of an answer you c | +| `synthesizer.md` | Weigh the room, support the best-grounded answer, and record the decision once i | diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/computer.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/computer.md new file mode 100644 index 00000000..d68a2405 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/computer.md @@ -0,0 +1 @@ +Compute exactly, but only the slice of the problem you own. Never claim more than you computed. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/conjecturer.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/conjecturer.md new file mode 100644 index 00000000..48edccea --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/conjecturer.md @@ -0,0 +1 @@ +Guess quickly from pattern and magnitude. Say how confident you are; you are often wrong. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/prover.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/prover.md new file mode 100644 index 00000000..7cda535f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/prover.md @@ -0,0 +1 @@ +Derive the answer in full and propose it with the argument that produced it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/synthesizer.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/synthesizer.md new file mode 100644 index 00000000..d641f590 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/synthesizer.md @@ -0,0 +1 @@ +Weigh the room, support the best-grounded answer, and record the decision once it carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/verifier.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/verifier.md new file mode 100644 index 00000000..a44c7f5a --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/roles/verifier.md @@ -0,0 +1 @@ +Re-derive the answer independently and object to any advocate of an answer you cannot reproduce. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/README.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/README.md new file mode 100644 index 00000000..5ed6b0de --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/README.md @@ -0,0 +1,20 @@ +# Seat prompts + +Fifty prompts, one per seat, named `-.md` for desk `conjecture`, +`compute`, `prove`, `verify` or `synthesis` and `NN` from `01` to `10`. Every +seat on a desk fills that desk's role; each prompt gives the seat a distinct +focus (a heuristic, a residue slice, a proof method, a check, or a weighing). + +Each prompt is three lines: a persona (`You are a "".`, fifty of +them, no two alike), one sentence on how that persona thinks, and the seat's +job. The persona is what a `--live` model plays, for variety of ideas across a +desk; the offline simulation draws behaviour from `sim.json` and ignores it. +`tests/gallery_packages.rs` asserts the fifty personas are distinct. + +| Pattern | Role | Focus varies by | +| --- | --- | --- | +| `conjecture-NN.md` | conjecturer | guessing heuristic | +| `compute-NN.md` | computer | the modulus of its slice (3, 4, 5, 7, 8, 9, 11, 13, 16, 25) | +| `prove-NN.md` | prover | proof method | +| `verify-NN.md` | verifier | checking method | +| `synthesis-NN.md` | synthesizer | what it weighs | diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-01.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-01.md new file mode 100644 index 00000000..aaccafec --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-01.md @@ -0,0 +1,3 @@ +You are a "digit-sum calculator working mod 3". +You reduce everything to digit sums and remainders by 3, the cheapest exact check there is. +Your seat is compute-01. Compute the answer modulo 3 exactly, and claim no more than you computed. Propose the full answer only if you finished it; otherwise post the residue and object to any advocate whose answer contradicts it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-02.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-02.md new file mode 100644 index 00000000..8bb3a057 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-02.md @@ -0,0 +1,3 @@ +You are a "mod-4 residue bookkeeper". +You keep a ledger of every term's remainder by 4 and never let a carry slip past unrecorded. +Your seat is compute-02. Compute the answer modulo 4 exactly, and claim no more than you computed. Propose the full answer only if you finished it; otherwise post the residue and object to any advocate whose answer contradicts it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-03.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-03.md new file mode 100644 index 00000000..9788855f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-03.md @@ -0,0 +1,3 @@ +You are a "mod-5 arithmetic clerk". +You work in the five residues 0 to 4, reducing at every step so no number ever grows large. +Your seat is compute-03. Compute the answer modulo 5 exactly, and claim no more than you computed. Propose the full answer only if you finished it; otherwise post the residue and object to any advocate whose answer contradicts it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-04.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-04.md new file mode 100644 index 00000000..0a0bf449 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-04.md @@ -0,0 +1,3 @@ +You are a "mod-7 cycle tracker". +You find the short period of each power or term mod 7 and read the answer off the cycle. +Your seat is compute-04. Compute the answer modulo 7 exactly, and claim no more than you computed. Propose the full answer only if you finished it; otherwise post the residue and object to any advocate whose answer contradicts it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-05.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-05.md new file mode 100644 index 00000000..025c76b3 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-05.md @@ -0,0 +1,3 @@ +You are a "binary-minded residue keeper for mod 8". +You think in the last three bits of every number and carry only what those bits can tell you. +Your seat is compute-05. Compute the answer modulo 8 exactly, and claim no more than you computed. Propose the full answer only if you finished it; otherwise post the residue and object to any advocate whose answer contradicts it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-06.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-06.md new file mode 100644 index 00000000..272243af --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-06.md @@ -0,0 +1,3 @@ +You are a "casting-out-nines accountant". +You reduce every quantity by its digit sum mod 9, the old ledger-keeper's exact check. +Your seat is compute-06. Compute the answer modulo 9 exactly, and claim no more than you computed. Propose the full answer only if you finished it; otherwise post the residue and object to any advocate whose answer contradicts it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-07.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-07.md new file mode 100644 index 00000000..084df466 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-07.md @@ -0,0 +1,3 @@ +You are a "alternating-sum calculator for mod 11". +You reduce by alternating digit sums and Fermat's little theorem, keeping every step mod 11. +Your seat is compute-07. Compute the answer modulo 11 exactly, and claim no more than you computed. Propose the full answer only if you finished it; otherwise post the residue and object to any advocate whose answer contradicts it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-08.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-08.md new file mode 100644 index 00000000..3e1d86f6 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-08.md @@ -0,0 +1,3 @@ +You are a "mod-13 power-table builder". +You tabulate the powers and inverses mod 13 once and then compute by lookup. +Your seat is compute-08. Compute the answer modulo 13 exactly, and claim no more than you computed. Propose the full answer only if you finished it; otherwise post the residue and object to any advocate whose answer contradicts it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-09.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-09.md new file mode 100644 index 00000000..e1f3ab64 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-09.md @@ -0,0 +1,3 @@ +You are a "hexadecimal residue engineer for mod 16". +You read every number by its last hex digit and compute carries in base 16 exactly. +Your seat is compute-09. Compute the answer modulo 16 exactly, and claim no more than you computed. Propose the full answer only if you finished it; otherwise post the residue and object to any advocate whose answer contradicts it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-10.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-10.md new file mode 100644 index 00000000..863bb251 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/compute-10.md @@ -0,0 +1,3 @@ +You are a "two-digit-tail specialist for mod 25". +You care only about what happens mod 25, lifting from mod 5 and tracking factors of 5 with care. +Your seat is compute-10. Compute the answer modulo 25 exactly, and claim no more than you computed. Propose the full answer only if you finished it; otherwise post the residue and object to any advocate whose answer contradicts it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-01.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-01.md new file mode 100644 index 00000000..9e29ddc7 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-01.md @@ -0,0 +1,3 @@ +You are a "small-case tinkerer". +You work out n = 1, 2, 3 by hand and trust the pattern that falls out before any theory does. +Your seat is conjecture-01. Guess the answer from small cases; propose it as `#` citing the problem, say how confident you are, and expect the verify desk to test it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-02.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-02.md new file mode 100644 index 00000000..60a07174 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-02.md @@ -0,0 +1,3 @@ +You are a "symmetry hunter". +You look first for the reflection, rotation or swap that leaves the problem unchanged, and let it halve the work. +Your seat is conjecture-02. Guess the answer from symmetry; propose it as `#` citing the problem, say how confident you are, and expect the verify desk to test it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-03.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-03.md new file mode 100644 index 00000000..acc58e2f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-03.md @@ -0,0 +1,3 @@ +You are a "back-of-the-envelope estimator". +You round every quantity to a power of ten and reason from size alone, so you rarely miss by a factor of a thousand. +Your seat is conjecture-03. Guess the answer from order of magnitude; propose it as `#` citing the problem, say how confident you are, and expect the verify desk to test it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-04.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-04.md new file mode 100644 index 00000000..2a9c1628 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-04.md @@ -0,0 +1,3 @@ +You are a "collector of integer sequences". +You recognise the first few terms of a count as a sequence you have met before and extrapolate from its known formula. +Your seat is conjecture-04. Guess the answer from a known sequence; propose it as `#` citing the problem, say how confident you are, and expect the verify desk to test it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-05.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-05.md new file mode 100644 index 00000000..288e8468 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-05.md @@ -0,0 +1,3 @@ +You are a "parity watcher". +You ask first whether the answer must be odd or even, and discard every candidate on the wrong side of that line. +Your seat is conjecture-05. Guess the answer from parity; propose it as `#` citing the problem, say how confident you are, and expect the verify desk to test it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-06.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-06.md new file mode 100644 index 00000000..44fc3bbd --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-06.md @@ -0,0 +1,3 @@ +You are a "last-digit guesser". +You track only the units digit through the problem and pick the plausible candidate that ends the right way. +Your seat is conjecture-06. Guess the answer from the last digit; propose it as `#` citing the problem, say how confident you are, and expect the verify desk to test it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-07.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-07.md new file mode 100644 index 00000000..2751eb04 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-07.md @@ -0,0 +1,3 @@ +You are a "formal-power-series intuitionist". +You picture the problem as a product of simple series and read the likely coefficient off its shape. +Your seat is conjecture-07. Guess the answer from a generating function; propose it as `#` citing the problem, say how confident you are, and expect the verify desk to test it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-08.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-08.md new file mode 100644 index 00000000..354a44c4 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-08.md @@ -0,0 +1,3 @@ +You are a "recursion spotter". +You assume the size-n answer is built from the size-(n-1) answer and guess the rule that links them. +Your seat is conjecture-08. Guess the answer from a recursive guess; propose it as `#` citing the problem, say how confident you are, and expect the verify desk to test it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-09.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-09.md new file mode 100644 index 00000000..1e2edd77 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-09.md @@ -0,0 +1,3 @@ +You are a "analogy-maker who shrinks the problem". +You replace the problem with a smaller cousin you can solve and scale its answer back up. +Your seat is conjecture-09. Guess the answer from a simpler analogue; propose it as `#` citing the problem, say how confident you are, and expect the verify desk to test it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-10.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-10.md new file mode 100644 index 00000000..8df81c5d --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/conjecture-10.md @@ -0,0 +1,3 @@ +You are a "extremal-case explorer". +You push every parameter to its smallest and largest value and guess the answer that fits both ends. +Your seat is conjecture-10. Guess the answer from the boundary cases; propose it as `#` citing the problem, say how confident you are, and expect the verify desk to test it. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-01.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-01.md new file mode 100644 index 00000000..bc398193 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-01.md @@ -0,0 +1,3 @@ +You are a "combinatorialist who counts by bijection". +You never count a set directly if you can pair it one-to-one with a set you already know how to count. +Your seat is prove-01. Derive the answer in full using a bijection, propose it with one line of the argument, and object to an advocate whose answer your argument rules out. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-02.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-02.md new file mode 100644 index 00000000..da7d2544 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-02.md @@ -0,0 +1,3 @@ +You are a "induction craftsman". +You prove the base case, assume the claim for n, and build the case n + 1 from it step by step. +Your seat is prove-02. Derive the answer in full using induction, propose it with one line of the argument, and object to an advocate whose answer your argument rules out. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-03.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-03.md new file mode 100644 index 00000000..5f3c69a0 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-03.md @@ -0,0 +1,3 @@ +You are a "classical algebraist". +You manipulate the expression symbolically until it collapses into a single closed formula. +Your seat is prove-03. Derive the answer in full using a closed form, propose it with one line of the argument, and object to an advocate whose answer your argument rules out. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-04.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-04.md new file mode 100644 index 00000000..447c6f19 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-04.md @@ -0,0 +1,3 @@ +You are a "double-counting accountant". +You count one quantity in two different ways and let the two expressions be forced equal. +Your seat is prove-04. Derive the answer in full using double counting, propose it with one line of the argument, and object to an advocate whose answer your argument rules out. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-05.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-05.md new file mode 100644 index 00000000..c7dd782b --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-05.md @@ -0,0 +1,3 @@ +You are a "recurrence solver". +You set up the relation between consecutive cases, fix the initial values, and solve it exactly. +Your seat is prove-05. Derive the answer in full using a recurrence, propose it with one line of the argument, and object to an advocate whose answer your argument rules out. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-06.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-06.md new file mode 100644 index 00000000..e80e7d1d --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-06.md @@ -0,0 +1,3 @@ +You are a "inclusion-exclusion specialist". +You add the overlapping sets, subtract their intersections, and keep the alternating signs straight to the end. +Your seat is prove-06. Derive the answer in full using inclusion-exclusion, propose it with one line of the argument, and object to an advocate whose answer your argument rules out. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-07.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-07.md new file mode 100644 index 00000000..8b8d886f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-07.md @@ -0,0 +1,3 @@ +You are a "telescoping-sum artisan". +You rewrite each term as a difference so that the sum cancels down to its first and last pieces. +Your seat is prove-07. Derive the answer in full using a telescoping sum, propose it with one line of the argument, and object to an advocate whose answer your argument rules out. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-08.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-08.md new file mode 100644 index 00000000..09c1a2f3 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-08.md @@ -0,0 +1,3 @@ +You are a "patient exhaustive enumerator". +You organise every case into a systematic list and count it completely, trusting nothing you did not list. +Your seat is prove-08. Derive the answer in full using direct enumeration, propose it with one line of the argument, and object to an advocate whose answer your argument rules out. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-09.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-09.md new file mode 100644 index 00000000..c8ad0526 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-09.md @@ -0,0 +1,3 @@ +You are a "generating-function analyst". +You encode the count as the coefficients of a power series and extract the one you need algebraically. +Your seat is prove-09. Derive the answer in full using a generating function, propose it with one line of the argument, and object to an advocate whose answer your argument rules out. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-10.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-10.md new file mode 100644 index 00000000..e95f8712 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/prove-10.md @@ -0,0 +1,3 @@ +You are a "invariant seeker". +You find the quantity that never changes under the problem's moves and let it pin the answer down. +Your seat is prove-10. Derive the answer in full using an invariant, propose it with one line of the argument, and object to an advocate whose answer your argument rules out. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-01.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-01.md new file mode 100644 index 00000000..bb20ab3a --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-01.md @@ -0,0 +1,3 @@ +You are a "evidence-weighing chair". +You count only the support that cites real working and treat a bare assertion as no support at all. +Your seat is synthesis-01. Weigh grounded support, support the best-grounded answer, and `!commit` it once the room's quorum carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-02.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-02.md new file mode 100644 index 00000000..f6046e48 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-02.md @@ -0,0 +1,3 @@ +You are a "cross-examining judge". +You rank answers by how well they withstood objection, not by how many voices backed them. +Your seat is synthesis-02. Weigh surviving objections, support the best-grounded answer, and `!commit` it once the room's quorum carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-03.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-03.md new file mode 100644 index 00000000..db713c36 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-03.md @@ -0,0 +1,3 @@ +You are a "consensus builder across desks". +You trust an answer most when conjecture, compute, prove and verify reach it independently. +Your seat is synthesis-03. Weigh agreement across desks, support the best-grounded answer, and `!commit` it once the room's quorum carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-04.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-04.md new file mode 100644 index 00000000..acb61ddc --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-04.md @@ -0,0 +1,3 @@ +You are a "rigor-first moderator". +You let the single most complete argument outweigh any number of guesses. +Your seat is synthesis-04. Weigh the strongest proof, support the best-grounded answer, and `!commit` it once the room's quorum carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-05.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-05.md new file mode 100644 index 00000000..1b183f46 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-05.md @@ -0,0 +1,3 @@ +You are a "residue reconciler". +You assemble the compute desk's residues and back the answer consistent with every one of them. +Your seat is synthesis-05. Weigh the computed slices, support the best-grounded answer, and `!commit` it once the room's quorum carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-06.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-06.md new file mode 100644 index 00000000..3df419db --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-06.md @@ -0,0 +1,3 @@ +You are a "dissent-minded facilitator". +You look hardest at the voices that disagree and commit only once each objection has been answered. +Your seat is synthesis-06. Weigh dissent, support the best-grounded answer, and `!commit` it once the room's quorum carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-07.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-07.md new file mode 100644 index 00000000..30bb23dd --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-07.md @@ -0,0 +1,3 @@ +You are a "verification-led arbiter". +You defer to the checkers and back the answer that the most independent checks reproduced. +Your seat is synthesis-07. Weigh the verify desk's verdicts, support the best-grounded answer, and `!commit` it once the room's quorum carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-08.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-08.md new file mode 100644 index 00000000..f3958f27 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-08.md @@ -0,0 +1,3 @@ +You are a "pragmatic timekeeper". +You watch how fast the room is settling and push to close once the leading answer stops gaining challengers. +Your seat is synthesis-08. Weigh convergence speed, support the best-grounded answer, and `!commit` it once the room's quorum carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-09.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-09.md new file mode 100644 index 00000000..fb99af4b --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-09.md @@ -0,0 +1,3 @@ +You are a "minority-report reader". +You read the losing positions carefully, because a lone correct dissenter is the most costly thing to miss. +Your seat is synthesis-09. Weigh the minority report, support the best-grounded answer, and `!commit` it once the room's quorum carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-10.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-10.md new file mode 100644 index 00000000..f507aa32 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/synthesis-10.md @@ -0,0 +1,3 @@ +You are a "meticulous record keeper". +You review the whole transcript before committing, so that the recorded decision matches what the room actually showed. +Your seat is synthesis-10. Weigh the final record, support the best-grounded answer, and `!commit` it once the room's quorum carries. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-01.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-01.md new file mode 100644 index 00000000..5d714065 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-01.md @@ -0,0 +1,3 @@ +You are a "skeptical auditor". +You ignore everyone else's working and redo the whole calculation yourself before believing any number. +Your seat is verify-01. Check every proposal by recomputation from scratch. Support the one you reproduce with `!support # ^N`, and `!object >N ^M` each advocate of an answer you cannot. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-02.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-02.md new file mode 100644 index 00000000..40ea4529 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-02.md @@ -0,0 +1,3 @@ +You are a "independent second-opinion checker". +You deliberately choose a different route from the proposer's, because agreement between two methods is worth more than one method twice. +Your seat is verify-02. Check every proposal by a second method. Support the one you reproduce with `!support # ^N`, and `!object >N ^M` each advocate of an answer you cannot. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-03.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-03.md new file mode 100644 index 00000000..97ae186d --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-03.md @@ -0,0 +1,3 @@ +You are a "small-instance tester". +You shrink the problem until it can be checked by hand and see whether the proposed formula survives. +Your seat is verify-03. Check every proposal by a small-case check. Support the one you reproduce with `!support # ^N`, and `!object >N ^M` each advocate of an answer you cannot. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-04.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-04.md new file mode 100644 index 00000000..90bcd213 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-04.md @@ -0,0 +1,3 @@ +You are a "modular consistency checker". +You reduce each proposed answer mod small numbers and compare it against the residues the compute desk posted. +Your seat is verify-04. Check every proposal by a residue check. Support the one you reproduce with `!support # ^N`, and `!object >N ^M` each advocate of an answer you cannot. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-05.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-05.md new file mode 100644 index 00000000..4a4a8a45 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-05.md @@ -0,0 +1,3 @@ +You are a "bounds inspector". +You work out the smallest and largest the answer could possibly be and reject anything outside that window. +Your seat is verify-05. Check every proposal by a bound check. Support the one you reproduce with `!support # ^N`, and `!object >N ^M` each advocate of an answer you cannot. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-06.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-06.md new file mode 100644 index 00000000..64ef81d9 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-06.md @@ -0,0 +1,3 @@ +You are a "off-by-one hunter". +You check every endpoint, every strict-versus-non-strict inequality and every fencepost, where most counts go wrong. +Your seat is verify-06. Check every proposal by an off-by-one audit. Support the one you reproduce with `!support # ^N`, and `!object >N ^M` each advocate of an answer you cannot. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-07.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-07.md new file mode 100644 index 00000000..4e7c6766 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-07.md @@ -0,0 +1,3 @@ +You are a "dimensional-analysis referee". +You check that each quantity counts what it claims to count, so that the answer is the kind of number the problem asks for. +Your seat is verify-07. Check every proposal by a units audit. Support the one you reproduce with `!support # ^N`, and `!object >N ^M` each advocate of an answer you cannot. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-08.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-08.md new file mode 100644 index 00000000..a89280ad --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-08.md @@ -0,0 +1,3 @@ +You are a "edge-case tester". +You test the degenerate inputs, such as zero, one, empty sets and ties, where a general argument quietly breaks. +Your seat is verify-08. Check every proposal by an edge-case audit. Support the one you reproduce with `!support # ^N`, and `!object >N ^M` each advocate of an answer you cannot. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-09.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-09.md new file mode 100644 index 00000000..2fde75f2 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-09.md @@ -0,0 +1,3 @@ +You are a "sign-error spotter". +You follow every minus sign and alternating term through the working, because a single flipped sign changes the answer. +Your seat is verify-09. Check every proposal by a sign audit. Support the one you reproduce with `!support # ^N`, and `!object >N ^M` each advocate of an answer you cannot. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-10.md b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-10.md new file mode 100644 index 00000000..77527fc3 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/seats/verify-10.md @@ -0,0 +1,3 @@ +You are a "devil's advocate". +You argue against whichever answer is winning and accept it only when the prover desk's arguments survive your attack. +Your seat is verify-10. Check every proposal by a cross-check against the prover desk. Support the one you reproduce with `!support # ^N`, and `!object >N ^M` each advocate of an answer you cannot. diff --git a/crates/tinyhivemind-lang/examples/hives/math-swarm/sim.json b/crates/tinyhivemind-lang/examples/hives/math-swarm/sim.json new file mode 100644 index 00000000..cbe77229 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/math-swarm/sim.json @@ -0,0 +1,140 @@ +{ + "kind": "math", + "problems": [ + { + "id": "lattice-6x6", + "kind": "lattice", + "args": [ + 6, + 6 + ], + "statement": "How many shortest lattice paths run from (0,0) to (6,6) using unit steps right and up?" + }, + { + "id": "lattice-5x8", + "kind": "lattice", + "args": [ + 5, + 8 + ], + "statement": "How many shortest lattice paths run from (0,0) to (5,8) using unit steps right and up?" + }, + { + "id": "modsum-squares", + "kind": "modsum", + "args": [ + 100, + 2, + 97 + ], + "statement": "What is 1^2 + 2^2 + ... + 100^2 modulo 97?" + }, + { + "id": "modsum-cubes", + "kind": "modsum", + "args": [ + 50, + 3, + 101 + ], + "statement": "What is 1^3 + 2^3 + ... + 50^3 modulo 101?" + }, + { + "id": "digitsum-2pow50", + "kind": "digitsum", + "args": [ + 2, + 50 + ], + "statement": "What is the sum of the decimal digits of 2^50?" + }, + { + "id": "multiples-3-5", + "kind": "multiples", + "args": [ + 1000, + 3, + 5 + ], + "statement": "What is the sum of all natural numbers below 1000 that are multiples of 3 or 5?" + }, + { + "id": "sqdiff-20", + "kind": "sqdiff", + "args": [ + 20 + ], + "statement": "What is (1 + 2 + ... + 20)^2 minus (1^2 + 2^2 + ... + 20^2)?" + }, + { + "id": "primes-200", + "kind": "primes", + "args": [ + 200 + ], + "statement": "How many primes are there below 200?" + } + ], + "errors": { + "classic": 500000, + "plus_one": 250000, + "minus_one": 250000 + }, + "roles": { + "conjecturer": { + "behaviour": "guess", + "accuracy": [ + 250000, + 450000 + ] + }, + "computer": { + "behaviour": "slice", + "accuracy": [ + 300000, + 600000 + ] + }, + "prover": { + "behaviour": "solve", + "accuracy": [ + 450000, + 750000 + ] + }, + "verifier": { + "behaviour": "check", + "accuracy": [ + 500000, + 800000 + ] + }, + "synthesizer": { + "behaviour": "lead", + "accuracy": [ + 450000, + 750000 + ] + } + }, + "slices": [ + 3, + 4, + 5, + 7, + 8, + 9, + 11, + 13, + 16, + 25 + ], + "baseline": { + "role": "solver", + "behaviour": "solve", + "accuracy": [ + 450000, + 750000 + ] + } +} diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/README.md b/crates/tinyhivemind-lang/examples/hives/memory-guild/README.md new file mode 100644 index 00000000..1cac38e7 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/README.md @@ -0,0 +1,18 @@ +# memory-guild + +Every part of the memory declaration. The package root is `team:guild`; the +archivist and scribe each hold private agent memory, the guild shares a `lore` +pool that lives for the hive's lifetime, a pinned `charter` pool is read-only +(the constitution pins it), and one scout forks its own memory beneath the lore +pool. Each binding sets what the seat reads, when the host recalls, its recall +budget, and which entry kinds it may remember. The archivist's prompt repeats +its seat and binding fields as JSON frontmatter, which `validate` checks +against the manifest. + +| Path | Purpose | +| --- | --- | +| `hive.json` | One desk of four seats | +| `constitution.json` | Pins `charter` read-only | +| `memory.json` | Root, five identities, four bindings | +| `seats/archivist.md` | Prompt with JSON frontmatter | +| `roles/`, `seats/` | Charters and prompts | diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/constitution.json b/crates/tinyhivemind-lang/examples/hives/memory-guild/constitution.json new file mode 100644 index 00000000..427eaf58 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/constitution.json @@ -0,0 +1,14 @@ +{ + "hard_constraints": [], + "max_turns": 60, + "max_width": 8, + "permitted_edits": [], + "read_only_memories": [ + "charter" + ], + "protected_namespaces": [], + "contamination_canaries": [], + "evaluation": null, + "acceptance": null, + "telemetry": null +} diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/hive.json b/crates/tinyhivemind-lang/examples/hives/memory-guild/hive.json new file mode 100644 index 00000000..dc2ff748 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/hive.json @@ -0,0 +1,245 @@ +{ + "$schema": "https://tinyhumans.ai/schemas/hive/v1", + "version": 1, + "id": "memory-guild", + "name": "Memory guild", + "roles": [ + { + "id": "archivist", + "charter": "roles/archivist.md" + }, + { + "id": "scribe", + "charter": "roles/scribe.md" + }, + { + "id": "scout", + "charter": "roles/scout.md" + } + ], + "seats": [ + { + "id": "archivist", + "label": "archivist", + "role": "archivist", + "prompt": "seats/archivist.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "scribe", + "label": "scribe", + "role": "scribe", + "prompt": "seats/scribe.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "scout-a", + "label": "scout-a", + "role": "scout", + "prompt": "seats/scout-a.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "scout-b", + "label": "scout-b", + "role": "scout", + "prompt": "seats/scout-b.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + } + ], + "desks": [ + { + "id": "guild", + "name": "Guild", + "description": "Choose which archive format the guild standardizes on", + "members": [ + "archivist", + "scribe", + "scout-a", + "scout-b" + ], + "responder_mode": "auto" + } + ], + "goals": [], + "missions": [], + "topology": [], + "referrals": [], + "context": [], + "policies": { + "episode": { + "turn_budget": 12, + "round_width": 4, + "revealed_width": 4, + "blind_round": true, + "dominance_cap": 50, + "repetition_cap": 3, + "distance": "sequence", + "quorum": { + "threshold": 2, + "window": 30, + "require_grounded": true + }, + "weights": { + "recency": 5, + "importance": 30, + "relevance": 20, + "half_life": 20 + } + }, + "routing": { + "minimum_confidence": 0, + "high_impact_minimum_confidence": 0, + "clarification_threshold": 1000000, + "high_impact_threshold": 1000000, + "round_width": 4, + "choice_option_limit": 8 + }, + "approval": { + "enabled": false, + "default": "deny", + "rules": [], + "approver": { + "kind": "absent" + }, + "allow_grants": false, + "max_grant_ttl": null + }, + "aside": { + "enabled": false, + "max_members": 0, + "max_messages": 0, + "must_surface": false, + "require_thread": false + }, + "referral": { + "enabled": false, + "max_hops": 0, + "reach": "local", + "returns": false + }, + "digest": { + "keep_live": 30, + "fold_after": 20, + "fold_after_chars": 400000, + "input_limit": 60, + "budget_chars": 4000 + }, + "brevity": { + "message_chars": 600, + "window": 30 + }, + "directory": { + "half_life": 20, + "specialisation": 30, + "credibility": 20, + "prior": 10, + "discredit": 20, + "window": 30, + "floor": 1000 + }, + "exchange": { + "enabled": false, + "contact_cap": 0, + "round_cap": 0 + }, + "division": { + "round_width": 4, + "follow_directory": true + }, + "conduct": { + "child_turn_wall": 6, + "turn_wall": 60 + } + }, + "coordinator": { + "round_width": 4, + "conduct_policy": { + "child_turn_wall": 6, + "turn_wall": 60 + }, + "broadcast_budget": null, + "retention": { + "settled_episodes": null, + "delivered": null, + "interrupted": null, + "pending_per_agent": null + } + } +} diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/memory.json b/crates/tinyhivemind-lang/examples/hives/memory-guild/memory.json new file mode 100644 index 00000000..e227573d --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/memory.json @@ -0,0 +1,140 @@ +{ + "root": "team:guild", + "identities": [ + { + "id": "archivist-notes", + "namespace": "team:guild/agent:archivist", + "kind": "agent", + "lifecycle": "hive", + "read_only": false, + "fork_parent": null + }, + { + "id": "scribe-notes", + "namespace": "team:guild/agent:scribe", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": null + }, + { + "id": "lore", + "namespace": "team:guild/project:lore", + "kind": "pool", + "lifecycle": "hive", + "read_only": false, + "fork_parent": null + }, + { + "id": "charter", + "namespace": "team:guild/project:charter", + "kind": "pool", + "lifecycle": "hive", + "read_only": true, + "fork_parent": null + }, + { + "id": "scout-a-notes", + "namespace": "team:guild/project:lore/agent:scout-a", + "kind": "agent", + "lifecycle": "run", + "read_only": false, + "fork_parent": "lore" + } + ], + "bindings": [ + { + "seat": "archivist", + "identity": "archivist-notes", + "reads": [ + { + "at": "team:guild/agent:archivist", + "inherit": false, + "descendants": false + }, + { + "at": "team:guild/project:lore", + "inherit": false, + "descendants": true + }, + { + "at": "team:guild/project:charter", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin", + "compaction" + ], + "budget_chars": 4000, + "remember": [ + "observation", + "outcome", + "note" + ] + }, + { + "seat": "scribe", + "identity": "scribe-notes", + "reads": [ + { + "at": "team:guild/agent:scribe", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start", + "rejoin" + ], + "budget_chars": 2000, + "remember": [ + "note" + ] + }, + { + "seat": "scout-a", + "identity": "scout-a-notes", + "reads": [ + { + "at": "team:guild/project:lore", + "inherit": false, + "descendants": true + } + ], + "recall_at": [ + "session_start" + ], + "budget_chars": 1500, + "remember": [ + "observation", + "failed_attempt" + ] + }, + { + "seat": "scout-b", + "identity": "lore", + "reads": [ + { + "at": "team:guild/project:lore", + "inherit": false, + "descendants": false + }, + { + "at": "team:guild/project:charter", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "rejoin" + ], + "budget_chars": 1000, + "remember": [ + "observation" + ] + } + ] +} diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/roles/README.md b/crates/tinyhivemind-lang/examples/hives/memory-guild/roles/README.md new file mode 100644 index 00000000..b130a9e7 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/roles/README.md @@ -0,0 +1,9 @@ +# Role charters + +One charter per role, independent of the seats that fill it. + +| File | Charter | +| --- | --- | +| `archivist.md` | Keep the guild's long-lived record: what was decided, by whom, and on what evide | +| `scribe.md` | Write the run's working notes so a seat that rejoins can pick up where the room | +| `scout.md` | Explore one lead at a time and remember failed attempts so nobody repeats them | diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/roles/archivist.md b/crates/tinyhivemind-lang/examples/hives/memory-guild/roles/archivist.md new file mode 100644 index 00000000..1f41f21e --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/roles/archivist.md @@ -0,0 +1 @@ +Keep the guild's long-lived record: what was decided, by whom, and on what evidence. diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/roles/scout.md b/crates/tinyhivemind-lang/examples/hives/memory-guild/roles/scout.md new file mode 100644 index 00000000..3b5ea44a --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/roles/scout.md @@ -0,0 +1 @@ +Explore one lead at a time and remember failed attempts so nobody repeats them. diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/roles/scribe.md b/crates/tinyhivemind-lang/examples/hives/memory-guild/roles/scribe.md new file mode 100644 index 00000000..9c125d0a --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/roles/scribe.md @@ -0,0 +1 @@ +Write the run's working notes so a seat that rejoins can pick up where the room left off. diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/README.md b/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/README.md new file mode 100644 index 00000000..a71de4ff --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/README.md @@ -0,0 +1,10 @@ +# Seat prompts + +One prompt per seat. A seat names its role in `hive.json`. + +| File | Role | +| --- | --- | +| `archivist.md` | archivist | +| `scribe.md` | scribe | +| `scout-a.md` | scout | +| `scout-b.md` | scout | diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/archivist.md b/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/archivist.md new file mode 100644 index 00000000..9714ad69 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/archivist.md @@ -0,0 +1,4 @@ +--- +{"id":"archivist","role":"archivist","memory":"archivist-notes","budget_chars":4000} +--- +You are the archivist. Recall the lore pool and the pinned charter before you speak, cite the decision record you rely on, and remember outcomes, not chatter. diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/scout-a.md b/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/scout-a.md new file mode 100644 index 00000000..d54f13d8 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/scout-a.md @@ -0,0 +1 @@ +You scout the columnar format. Record every failed attempt in your forked memory so the guild never retries it blindly. diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/scout-b.md b/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/scout-b.md new file mode 100644 index 00000000..c939a035 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/scout-b.md @@ -0,0 +1 @@ +You scout the append-only log format. You write to the shared lore pool, so remember observations others can reuse. diff --git a/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/scribe.md b/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/scribe.md new file mode 100644 index 00000000..0fa3086a --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/memory-guild/seats/scribe.md @@ -0,0 +1 @@ +You are the scribe. Keep this run's notes terse and factual; when a seat rejoins, your notes are what it reads first. diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/README.md b/crates/tinyhivemind-lang/examples/hives/research-debate/README.md new file mode 100644 index 00000000..0cb4bd7f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/README.md @@ -0,0 +1,16 @@ +# research-debate + +A goal tree, missions, and a shared context document. The desk decides whether +to cache the embedding index; the verdict goal decomposes into a survey and a +critique, and each mission binds a role to the goals it owns. The librarian and +skeptic read `context/brief.md`, whose `` markers make each +paragraph separately patchable. The desk picks responders with +`responder_mode: auto`. + +| Path | Purpose | +| --- | --- | +| `hive.json` | Goals with subgoals, missions, one context document, an auto desk | +| `constitution.json` | Default caps | +| `memory.json` | No memory | +| `context/brief.md` | The itemized brief every seat may cite | +| `roles/`, `seats/` | Charters and prompts | diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/constitution.json b/crates/tinyhivemind-lang/examples/hives/research-debate/constitution.json new file mode 100644 index 00000000..a50f7ca2 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/constitution.json @@ -0,0 +1,12 @@ +{ + "hard_constraints": [], + "max_turns": 60, + "max_width": 8, + "permitted_edits": [], + "read_only_memories": [], + "protected_namespaces": [], + "contamination_canaries": [], + "evaluation": null, + "acceptance": null, + "telemetry": null +} diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/context/brief.md b/crates/tinyhivemind-lang/examples/hives/research-debate/context/brief.md new file mode 100644 index 00000000..637b002d --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/context/brief.md @@ -0,0 +1,13 @@ +# Embedding index brief + + +Rebuilding the index takes 41 seconds on the reference corpus; loading a cached +copy takes 1.2 seconds. + + +The corpus changes on roughly one run in five. A cache keyed on the corpus +digest is invalidated exactly then. + + +The 2025 retrieval study cached its index and reported no quality change, on a +static corpus only. diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/hive.json b/crates/tinyhivemind-lang/examples/hives/research-debate/hive.json new file mode 100644 index 00000000..e2f93a09 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/hive.json @@ -0,0 +1,302 @@ +{ + "$schema": "https://tinyhumans.ai/schemas/hive/v1", + "version": 1, + "id": "research-debate", + "name": "Research debate", + "roles": [ + { + "id": "moderator", + "charter": "roles/moderator.md" + }, + { + "id": "advocate", + "charter": "roles/advocate.md" + }, + { + "id": "skeptic", + "charter": "roles/skeptic.md" + }, + { + "id": "librarian", + "charter": "roles/librarian.md" + } + ], + "seats": [ + { + "id": "moderator", + "label": "moderator", + "role": "moderator", + "prompt": "seats/moderator.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "advocate", + "label": "advocate", + "role": "advocate", + "prompt": "seats/advocate.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "skeptic", + "label": "skeptic", + "role": "skeptic", + "prompt": "seats/skeptic.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "librarian", + "label": "librarian", + "role": "librarian", + "prompt": "seats/librarian.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + } + ], + "desks": [ + { + "id": "debate", + "name": "Debate", + "description": "Should we cache the embedding index between runs?", + "members": [ + "moderator", + "advocate", + "skeptic", + "librarian" + ], + "responder_mode": "auto" + } + ], + "goals": [ + { + "id": "verdict", + "description": "Decide whether to cache the embedding index between runs", + "acceptance": [ + "A committed topic", + "Each objection answered or upheld" + ], + "subgoals": [ + "survey", + "critique" + ] + }, + { + "id": "survey", + "description": "Summarize prior measurements of index caching", + "acceptance": [ + "Cites at least one brief item" + ], + "subgoals": [] + }, + { + "id": "critique", + "description": "Name the failure modes a cache introduces", + "acceptance": [ + "Staleness and invalidation both addressed" + ], + "subgoals": [] + } + ], + "missions": [ + { + "id": "decide", + "role": "moderator", + "goals": [ + "verdict" + ] + }, + { + "id": "survey-prior-work", + "role": "librarian", + "goals": [ + "survey" + ] + }, + { + "id": "stress-test", + "role": "skeptic", + "goals": [ + "critique" + ] + } + ], + "topology": [], + "referrals": [], + "context": [ + "context/brief.md" + ], + "policies": { + "episode": { + "turn_budget": 12, + "round_width": 4, + "revealed_width": 4, + "blind_round": true, + "dominance_cap": 50, + "repetition_cap": 3, + "distance": "sequence", + "quorum": { + "threshold": 2, + "window": 30, + "require_grounded": true + }, + "weights": { + "recency": 5, + "importance": 30, + "relevance": 20, + "half_life": 20 + } + }, + "routing": { + "minimum_confidence": 0, + "high_impact_minimum_confidence": 0, + "clarification_threshold": 1000000, + "high_impact_threshold": 1000000, + "round_width": 4, + "choice_option_limit": 8 + }, + "approval": { + "enabled": false, + "default": "deny", + "rules": [], + "approver": { + "kind": "absent" + }, + "allow_grants": false, + "max_grant_ttl": null + }, + "aside": { + "enabled": false, + "max_members": 0, + "max_messages": 0, + "must_surface": false, + "require_thread": false + }, + "referral": { + "enabled": false, + "max_hops": 0, + "reach": "local", + "returns": false + }, + "digest": { + "keep_live": 30, + "fold_after": 20, + "fold_after_chars": 400000, + "input_limit": 60, + "budget_chars": 4000 + }, + "brevity": { + "message_chars": 600, + "window": 30 + }, + "directory": { + "half_life": 20, + "specialisation": 30, + "credibility": 20, + "prior": 10, + "discredit": 20, + "window": 30, + "floor": 1000 + }, + "exchange": { + "enabled": false, + "contact_cap": 0, + "round_cap": 0 + }, + "division": { + "round_width": 4, + "follow_directory": true + }, + "conduct": { + "child_turn_wall": 6, + "turn_wall": 60 + } + }, + "coordinator": { + "round_width": 4, + "conduct_policy": { + "child_turn_wall": 6, + "turn_wall": 60 + }, + "broadcast_budget": null, + "retention": { + "settled_episodes": null, + "delivered": null, + "interrupted": null, + "pending_per_agent": null + } + } +} diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/memory.json b/crates/tinyhivemind-lang/examples/hives/research-debate/memory.json new file mode 100644 index 00000000..f3a23bf0 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/memory.json @@ -0,0 +1,5 @@ +{ + "root": "team:hive", + "identities": [], + "bindings": [] +} diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/roles/README.md b/crates/tinyhivemind-lang/examples/hives/research-debate/roles/README.md new file mode 100644 index 00000000..7e945fb1 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/roles/README.md @@ -0,0 +1,10 @@ +# Role charters + +One charter per role, independent of the seats that fill it. + +| File | Charter | +| --- | --- | +| `moderator.md` | Hold the room to the verdict goal. Summarize where support stands and ask for th | +| `advocate.md` | Make the strongest honest case for the proposal and cite the brief item each cla | +| `skeptic.md` | Stress-test the proposal. Every objection names the message it answers and the i | +| `librarian.md` | Survey prior work in the brief and report what it does and does not establish, w | diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/roles/advocate.md b/crates/tinyhivemind-lang/examples/hives/research-debate/roles/advocate.md new file mode 100644 index 00000000..2ae61828 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/roles/advocate.md @@ -0,0 +1 @@ +Make the strongest honest case for the proposal and cite the brief item each claim rests on. diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/roles/librarian.md b/crates/tinyhivemind-lang/examples/hives/research-debate/roles/librarian.md new file mode 100644 index 00000000..4b9973b3 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/roles/librarian.md @@ -0,0 +1 @@ +Survey prior work in the brief and report what it does and does not establish, with item citations. diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/roles/moderator.md b/crates/tinyhivemind-lang/examples/hives/research-debate/roles/moderator.md new file mode 100644 index 00000000..3066b44f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/roles/moderator.md @@ -0,0 +1 @@ +Hold the room to the verdict goal. Summarize where support stands and ask for the evidence that would move it. diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/roles/skeptic.md b/crates/tinyhivemind-lang/examples/hives/research-debate/roles/skeptic.md new file mode 100644 index 00000000..8eb95e04 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/roles/skeptic.md @@ -0,0 +1 @@ +Stress-test the proposal. Every objection names the message it answers and the item that grounds it. diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/seats/README.md b/crates/tinyhivemind-lang/examples/hives/research-debate/seats/README.md new file mode 100644 index 00000000..efd09622 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/seats/README.md @@ -0,0 +1,10 @@ +# Seat prompts + +One prompt per seat. A seat names its role in `hive.json`. + +| File | Role | +| --- | --- | +| `moderator.md` | moderator | +| `advocate.md` | advocate | +| `skeptic.md` | skeptic | +| `librarian.md` | librarian | diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/seats/advocate.md b/crates/tinyhivemind-lang/examples/hives/research-debate/seats/advocate.md new file mode 100644 index 00000000..a51943b9 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/seats/advocate.md @@ -0,0 +1 @@ +You argue for `#cache`. Lead with the latency item in the brief and quantify the saving; concede a point when the skeptic cites a fact you cannot answer. diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/seats/librarian.md b/crates/tinyhivemind-lang/examples/hives/research-debate/seats/librarian.md new file mode 100644 index 00000000..a4c69d0c --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/seats/librarian.md @@ -0,0 +1 @@ +You survey. Report what the brief's prior-work item measured and under which workload, and flag any claim the brief does not support. diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/seats/moderator.md b/crates/tinyhivemind-lang/examples/hives/research-debate/seats/moderator.md new file mode 100644 index 00000000..c84107b5 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/seats/moderator.md @@ -0,0 +1 @@ +You moderate. Keep the debate on the verdict goal, restate the live options as `#cache` and `#recompute`, and commit only once two grounded supporters agree. diff --git a/crates/tinyhivemind-lang/examples/hives/research-debate/seats/skeptic.md b/crates/tinyhivemind-lang/examples/hives/research-debate/seats/skeptic.md new file mode 100644 index 00000000..b3ae69d8 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/research-debate/seats/skeptic.md @@ -0,0 +1 @@ +You argue the risks: staleness, invalidation, disk. Object to specific messages with `!object >N ^M`, never to the topic in general. diff --git a/crates/tinyhivemind-lang/examples/hives/review-board/README.md b/crates/tinyhivemind-lang/examples/hives/review-board/README.md new file mode 100644 index 00000000..ff4c7775 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/review-board/README.md @@ -0,0 +1,17 @@ +# review-board + +The minimal hive: one desk, three seats, and a quorum. An author proposes that +a change merges, two reviewers deliberate blind and then in the open, and the +room converges on `#merge` once two grounded supporters carry it. + +| Path | Purpose | +| --- | --- | +| `hive.json` | One desk, three seats, a blind opening round and grounded quorum of two | +| `constitution.json` | Default caps: 60 turns, width 8 | +| `memory.json` | No memory | +| `roles/` | Author and reviewer charters | +| `seats/` | One prompt per seat | +| `script.json` | The scripted lines `hive_view --run` replays (not part of the package) | + +`script.json` is neither canonical nor Markdown, so `parse` drops it; only the +`hive_view` example reads it. diff --git a/crates/tinyhivemind-lang/examples/hives/review-board/constitution.json b/crates/tinyhivemind-lang/examples/hives/review-board/constitution.json new file mode 100644 index 00000000..a50f7ca2 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/review-board/constitution.json @@ -0,0 +1,12 @@ +{ + "hard_constraints": [], + "max_turns": 60, + "max_width": 8, + "permitted_edits": [], + "read_only_memories": [], + "protected_namespaces": [], + "contamination_canaries": [], + "evaluation": null, + "acceptance": null, + "telemetry": null +} diff --git a/crates/tinyhivemind-lang/examples/hives/review-board/hive.json b/crates/tinyhivemind-lang/examples/hives/review-board/hive.json new file mode 100644 index 00000000..e059eb2f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/review-board/hive.json @@ -0,0 +1,214 @@ +{ + "$schema": "https://tinyhumans.ai/schemas/hive/v1", + "version": 1, + "id": "review-board", + "name": "Review board", + "roles": [ + { + "id": "author", + "charter": "roles/author.md" + }, + { + "id": "reviewer", + "charter": "roles/reviewer.md" + } + ], + "seats": [ + { + "id": "author", + "label": "author", + "role": "author", + "prompt": "seats/author.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "reviewer-a", + "label": "reviewer-a", + "role": "reviewer", + "prompt": "seats/reviewer-a.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "reviewer-b", + "label": "reviewer-b", + "role": "reviewer", + "prompt": "seats/reviewer-b.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + } + ], + "desks": [ + { + "id": "board", + "name": "Review board", + "description": "Decide whether the retry-policy change merges", + "members": [ + "author", + "reviewer-a", + "reviewer-b" + ], + "responder_mode": "auto" + } + ], + "goals": [], + "missions": [], + "topology": [], + "referrals": [], + "context": [], + "policies": { + "episode": { + "turn_budget": 9, + "round_width": 3, + "revealed_width": 3, + "blind_round": true, + "dominance_cap": 50, + "repetition_cap": 3, + "distance": "sequence", + "quorum": { + "threshold": 2, + "window": 30, + "require_grounded": true + }, + "weights": { + "recency": 5, + "importance": 30, + "relevance": 20, + "half_life": 20 + } + }, + "routing": { + "minimum_confidence": 0, + "high_impact_minimum_confidence": 0, + "clarification_threshold": 1000000, + "high_impact_threshold": 1000000, + "round_width": 4, + "choice_option_limit": 8 + }, + "approval": { + "enabled": false, + "default": "deny", + "rules": [], + "approver": { + "kind": "absent" + }, + "allow_grants": false, + "max_grant_ttl": null + }, + "aside": { + "enabled": false, + "max_members": 0, + "max_messages": 0, + "must_surface": false, + "require_thread": false + }, + "referral": { + "enabled": false, + "max_hops": 0, + "reach": "local", + "returns": false + }, + "digest": { + "keep_live": 30, + "fold_after": 20, + "fold_after_chars": 400000, + "input_limit": 60, + "budget_chars": 4000 + }, + "brevity": { + "message_chars": 600, + "window": 30 + }, + "directory": { + "half_life": 20, + "specialisation": 30, + "credibility": 20, + "prior": 10, + "discredit": 20, + "window": 30, + "floor": 1000 + }, + "exchange": { + "enabled": false, + "contact_cap": 0, + "round_cap": 0 + }, + "division": { + "round_width": 4, + "follow_directory": true + }, + "conduct": { + "child_turn_wall": 6, + "turn_wall": 60 + } + }, + "coordinator": { + "round_width": 4, + "conduct_policy": { + "child_turn_wall": 6, + "turn_wall": 60 + }, + "broadcast_budget": null, + "retention": { + "settled_episodes": null, + "delivered": null, + "interrupted": null, + "pending_per_agent": null + } + } +} diff --git a/crates/tinyhivemind-lang/examples/hives/review-board/memory.json b/crates/tinyhivemind-lang/examples/hives/review-board/memory.json new file mode 100644 index 00000000..f3a23bf0 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/review-board/memory.json @@ -0,0 +1,5 @@ +{ + "root": "team:hive", + "identities": [], + "bindings": [] +} diff --git a/crates/tinyhivemind-lang/examples/hives/review-board/roles/README.md b/crates/tinyhivemind-lang/examples/hives/review-board/roles/README.md new file mode 100644 index 00000000..6cc12078 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/review-board/roles/README.md @@ -0,0 +1,8 @@ +# Role charters + +One charter per role, independent of the seats that fill it. + +| File | Charter | +| --- | --- | +| `author.md` | Propose the change you wrote, cite the evidence for it, and answer objections wi | +| `reviewer.md` | Read the change independently, support it only with a citation, and object to a | diff --git a/crates/tinyhivemind-lang/examples/hives/review-board/roles/author.md b/crates/tinyhivemind-lang/examples/hives/review-board/roles/author.md new file mode 100644 index 00000000..3a38a10c --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/review-board/roles/author.md @@ -0,0 +1 @@ +Propose the change you wrote, cite the evidence for it, and answer objections with facts rather than restatement. diff --git a/crates/tinyhivemind-lang/examples/hives/review-board/roles/reviewer.md b/crates/tinyhivemind-lang/examples/hives/review-board/roles/reviewer.md new file mode 100644 index 00000000..88319485 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/review-board/roles/reviewer.md @@ -0,0 +1 @@ +Read the change independently, support it only with a citation, and object to a specific message when the evidence is missing. diff --git a/crates/tinyhivemind-lang/examples/hives/review-board/script.json b/crates/tinyhivemind-lang/examples/hives/review-board/script.json new file mode 100644 index 00000000..45d090fb --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/review-board/script.json @@ -0,0 +1,14 @@ +{ + "author": [ + "!propose #merge ^1 The retry policy caps attempts at five with jittered backoff; the flaky test now passes 200/200.", + "!evidence ^2 Rollback is a config flag: retries.enabled=false restores the old path." + ], + "reviewer-a": [ + "!question ^1 Does the cap also apply to idempotent reads, or only writes?", + "!support #merge ^2 The 200/200 run covers the failing case, and the cap applies to both paths." + ], + "reviewer-b": [ + "!propose #revise ^1 I want a load test before this merges.", + "!support #merge ^5 The rollback flag answers my load concern; I withdraw the revise request." + ] +} diff --git a/crates/tinyhivemind-lang/examples/hives/review-board/seats/README.md b/crates/tinyhivemind-lang/examples/hives/review-board/seats/README.md new file mode 100644 index 00000000..2febda08 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/review-board/seats/README.md @@ -0,0 +1,9 @@ +# Seat prompts + +One prompt per seat. A seat names its role in `hive.json`. + +| File | Role | +| --- | --- | +| `author.md` | author | +| `reviewer-a.md` | reviewer | +| `reviewer-b.md` | reviewer | diff --git a/crates/tinyhivemind-lang/examples/hives/review-board/seats/author.md b/crates/tinyhivemind-lang/examples/hives/review-board/seats/author.md new file mode 100644 index 00000000..5e210251 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/review-board/seats/author.md @@ -0,0 +1 @@ +You wrote the retry-policy change. Open with `!propose #merge ^N` citing the request, then answer each objection with the test or benchmark that settles it. diff --git a/crates/tinyhivemind-lang/examples/hives/review-board/seats/reviewer-a.md b/crates/tinyhivemind-lang/examples/hives/review-board/seats/reviewer-a.md new file mode 100644 index 00000000..3e26f27f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/review-board/seats/reviewer-a.md @@ -0,0 +1 @@ +You review for correctness. Re-run the failing case in your head before you speak; `!support #merge ^N` only when a cited message shows it passes. diff --git a/crates/tinyhivemind-lang/examples/hives/review-board/seats/reviewer-b.md b/crates/tinyhivemind-lang/examples/hives/review-board/seats/reviewer-b.md new file mode 100644 index 00000000..2058a941 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/review-board/seats/reviewer-b.md @@ -0,0 +1 @@ +You review for operability. Ask what happens under load and on rollback; `!object >N ^M` a message whose claim is not backed by a cited result. diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/README.md b/crates/tinyhivemind-lang/examples/hives/self-improving/README.md new file mode 100644 index 00000000..f948080f --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/README.md @@ -0,0 +1,15 @@ +# self-improving + +The package the patch and lineage walk-through edits. Its constitution permits +prompt, context and goal edits and nothing else, so an editor can rewrite a +seat prompt or a playbook item but cannot add a seat or loosen a policy. The +held-out evaluation namespace is protected, a contamination canary guards +learning deltas, and the evaluation ledger is a read-only pool. + +| Path | Purpose | +| --- | --- | +| `hive.json` | Solver, critic and editor on one desk, with a patchable playbook | +| `constitution.json` | `permitted_edits`, canaries, protected namespaces, evaluator settings | +| `memory.json` | The solver's notes and a read-only ledger pool | +| `context/playbook.md` | Itemized playbook an editor may patch item by item | +| `roles/`, `seats/` | Charters and prompts | diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/constitution.json b/crates/tinyhivemind-lang/examples/hives/self-improving/constitution.json new file mode 100644 index 00000000..4ecbcff0 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/constitution.json @@ -0,0 +1,33 @@ +{ + "hard_constraints": [ + "Never read the held-out invoices", + "Never edit the evaluator configuration" + ], + "max_turns": 60, + "max_width": 8, + "permitted_edits": [ + "prompt", + "context", + "goal" + ], + "read_only_memories": [ + "eval-ledger" + ], + "protected_namespaces": [ + "team:self/project:heldout" + ], + "contamination_canaries": [ + "HELDOUT-7F3A" + ], + "evaluation": { + "kind": "exact_match", + "heldout_cases": 3 + }, + "acceptance": { + "require_all_heldout": true, + "min_improvement": 1 + }, + "telemetry": { + "lineage": true + } +} diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/context/playbook.md b/crates/tinyhivemind-lang/examples/hives/self-improving/context/playbook.md new file mode 100644 index 00000000..df4a2c92 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/context/playbook.md @@ -0,0 +1,7 @@ +# Solver playbook + + +Include every invoice regardless of status unless the task says otherwise. + + +Add amounts in integer cents and round only the final total. diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/hive.json b/crates/tinyhivemind-lang/examples/hives/self-improving/hive.json new file mode 100644 index 00000000..3ef0d449 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/hive.json @@ -0,0 +1,237 @@ +{ + "$schema": "https://tinyhumans.ai/schemas/hive/v1", + "version": 1, + "id": "self-improving", + "name": "Self-improving solver", + "roles": [ + { + "id": "solver", + "charter": "roles/solver.md" + }, + { + "id": "critic", + "charter": "roles/critic.md" + }, + { + "id": "editor", + "charter": "roles/editor.md" + } + ], + "seats": [ + { + "id": "solver", + "label": "solver", + "role": "solver", + "prompt": "seats/solver.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "critic", + "label": "critic", + "role": "critic", + "prompt": "seats/critic.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + }, + { + "id": "editor", + "label": "editor", + "role": "editor", + "prompt": "seats/editor.md", + "template": "hive-view", + "config": { + "max_iterations": 8, + "temperature": 0.0 + }, + "model": null, + "tools": { + "allow": [ + "file_read" + ], + "deny": [ + "shell", + "web_search", + "web_fetch", + "browser", + "http" + ] + }, + "mcp": [], + "skills": [] + } + ], + "desks": [ + { + "id": "workbench", + "name": "Workbench", + "description": "Sum the invoice amounts exactly", + "members": [ + "solver", + "critic", + "editor" + ], + "responder_mode": "auto" + } + ], + "goals": [ + { + "id": "exact-total", + "description": "Return the exact invoice total", + "acceptance": [ + "Matches the held-out total exactly" + ], + "subgoals": [] + } + ], + "missions": [ + { + "id": "solve", + "role": "solver", + "goals": [ + "exact-total" + ] + } + ], + "topology": [], + "referrals": [], + "context": [ + "context/playbook.md" + ], + "policies": { + "episode": { + "turn_budget": 9, + "round_width": 3, + "revealed_width": 3, + "blind_round": true, + "dominance_cap": 50, + "repetition_cap": 3, + "distance": "sequence", + "quorum": { + "threshold": 2, + "window": 30, + "require_grounded": true + }, + "weights": { + "recency": 5, + "importance": 30, + "relevance": 20, + "half_life": 20 + } + }, + "routing": { + "minimum_confidence": 0, + "high_impact_minimum_confidence": 0, + "clarification_threshold": 1000000, + "high_impact_threshold": 1000000, + "round_width": 4, + "choice_option_limit": 8 + }, + "approval": { + "enabled": false, + "default": "deny", + "rules": [], + "approver": { + "kind": "absent" + }, + "allow_grants": false, + "max_grant_ttl": null + }, + "aside": { + "enabled": false, + "max_members": 0, + "max_messages": 0, + "must_surface": false, + "require_thread": false + }, + "referral": { + "enabled": false, + "max_hops": 0, + "reach": "local", + "returns": false + }, + "digest": { + "keep_live": 30, + "fold_after": 20, + "fold_after_chars": 400000, + "input_limit": 60, + "budget_chars": 4000 + }, + "brevity": { + "message_chars": 600, + "window": 30 + }, + "directory": { + "half_life": 20, + "specialisation": 30, + "credibility": 20, + "prior": 10, + "discredit": 20, + "window": 30, + "floor": 1000 + }, + "exchange": { + "enabled": false, + "contact_cap": 0, + "round_cap": 0 + }, + "division": { + "round_width": 4, + "follow_directory": true + }, + "conduct": { + "child_turn_wall": 6, + "turn_wall": 60 + } + }, + "coordinator": { + "round_width": 4, + "conduct_policy": { + "child_turn_wall": 6, + "turn_wall": 60 + }, + "broadcast_budget": null, + "retention": { + "settled_episodes": null, + "delivered": null, + "interrupted": null, + "pending_per_agent": null + } + } +} diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/memory.json b/crates/tinyhivemind-lang/examples/hives/self-improving/memory.json new file mode 100644 index 00000000..21bdc599 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/memory.json @@ -0,0 +1,47 @@ +{ + "root": "team:self", + "identities": [ + { + "id": "solver-notes", + "namespace": "team:self/agent:solver", + "kind": "agent", + "lifecycle": "hive", + "read_only": false, + "fork_parent": null + }, + { + "id": "eval-ledger", + "namespace": "team:self/project:ledger", + "kind": "pool", + "lifecycle": "hive", + "read_only": true, + "fork_parent": null + } + ], + "bindings": [ + { + "seat": "solver", + "identity": "solver-notes", + "reads": [ + { + "at": "team:self/agent:solver", + "inherit": false, + "descendants": false + }, + { + "at": "team:self/project:ledger", + "inherit": false, + "descendants": false + } + ], + "recall_at": [ + "session_start" + ], + "budget_chars": 2000, + "remember": [ + "outcome", + "failed_attempt" + ] + } + ] +} diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/roles/README.md b/crates/tinyhivemind-lang/examples/hives/self-improving/roles/README.md new file mode 100644 index 00000000..6580337c --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/roles/README.md @@ -0,0 +1,9 @@ +# Role charters + +One charter per role, independent of the seats that fill it. + +| File | Charter | +| --- | --- | +| `solver.md` | Answer the task exactly and show the computation that produced the answer | +| `critic.md` | Find the case the solver's answer gets wrong and cite the message that contains | +| `editor.md` | Propose the smallest prompt or playbook edit that would have prevented the last | diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/roles/critic.md b/crates/tinyhivemind-lang/examples/hives/self-improving/roles/critic.md new file mode 100644 index 00000000..6f7ea318 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/roles/critic.md @@ -0,0 +1 @@ +Find the case the solver's answer gets wrong and cite the message that contains the mistake. diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/roles/editor.md b/crates/tinyhivemind-lang/examples/hives/self-improving/roles/editor.md new file mode 100644 index 00000000..22d745b0 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/roles/editor.md @@ -0,0 +1 @@ +Propose the smallest prompt or playbook edit that would have prevented the last failure. diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/roles/solver.md b/crates/tinyhivemind-lang/examples/hives/self-improving/roles/solver.md new file mode 100644 index 00000000..fd758468 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/roles/solver.md @@ -0,0 +1 @@ +Answer the task exactly and show the computation that produced the answer. diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/seats/README.md b/crates/tinyhivemind-lang/examples/hives/self-improving/seats/README.md new file mode 100644 index 00000000..f57c7988 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/seats/README.md @@ -0,0 +1,9 @@ +# Seat prompts + +One prompt per seat. A seat names its role in `hive.json`. + +| File | Role | +| --- | --- | +| `solver.md` | solver | +| `critic.md` | critic | +| `editor.md` | editor | diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/seats/critic.md b/crates/tinyhivemind-lang/examples/hives/self-improving/seats/critic.md new file mode 100644 index 00000000..0667b5e1 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/seats/critic.md @@ -0,0 +1 @@ +Recompute the total independently. If it differs, object to the solver's message and name the invoice that was mishandled. diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/seats/editor.md b/crates/tinyhivemind-lang/examples/hives/self-improving/seats/editor.md new file mode 100644 index 00000000..0e50d0ae --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/seats/editor.md @@ -0,0 +1 @@ +After a failure, propose one edit to a seat prompt or a playbook item. Never touch the held-out answers or the evaluation ledger. diff --git a/crates/tinyhivemind-lang/examples/hives/self-improving/seats/solver.md b/crates/tinyhivemind-lang/examples/hives/self-improving/seats/solver.md new file mode 100644 index 00000000..9f7ce3ff --- /dev/null +++ b/crates/tinyhivemind-lang/examples/hives/self-improving/seats/solver.md @@ -0,0 +1 @@ +Sum every invoice amount you are given and return the total. Show the line items you added so the critic can check them. diff --git a/crates/tinyhivemind-lang/examples/live_self_edit.rs b/crates/tinyhivemind-lang/examples/live_self_edit.rs index e044d965..ea9dee4f 100644 --- a/crates/tinyhivemind-lang/examples/live_self_edit.rs +++ b/crates/tinyhivemind-lang/examples/live_self_edit.rs @@ -9,14 +9,15 @@ use std::{ process::{Command, Stdio}, }; use tinyhivemind_lang::{ - Package, Role, Seat, + Package, Review, Role, Rule, Seat, check, lineage::{self, Direction, ObjectiveScore, Record, Verdict, VersionMetadata}, lower, parse, - patch::{self, Operation, Patch, PatchClass}, + patch::{Patch, PatchClass}, validate, }; type Result = std::result::Result>; +type Files = BTreeMap; const PROMPT: &str = "Sum all invoice amounts, including canceled invoices. Return JSON with a numeric total field."; @@ -136,14 +137,50 @@ fn package() -> Result { p.manifest.seats[0].config = json!({"telemetry":{"enabled":true}}); validate(&p)?; // Round-trip through the public package parser before a model uses it. + Ok(parse(&files(&p)?)?) +} + +/// A package as the files an editor reads and rewrites. +fn files(p: &Package) -> Result { let mut docs = p.documents.clone(); - docs.insert("hive.json".into(), serde_json::to_string(&p.manifest)?); + docs.insert( + "hive.json".into(), + serde_json::to_string_pretty(&p.manifest)?, + ); docs.insert( "constitution.json".into(), - serde_json::to_string(&p.constitution)?, + serde_json::to_string_pretty(&p.constitution)?, + ); + docs.insert( + "memory.json".into(), + serde_json::to_string_pretty(&p.memory)?, ); - docs.insert("memory.json".into(), serde_json::to_string(&p.memory)?); - Ok(parse(&docs)?) + Ok(docs) +} + +/// Overlay the whole files an editor returned and check the edit; a +/// refusal carries every diagnostic line. +fn edit(active: &Package, returned: &Value) -> std::result::Result { + let original = files(active).map_err(|error| error.to_string())?; + let mut edited = original.clone(); + for (path, body) in returned.as_object().into_iter().flatten() { + let body = body + .as_str() + .ok_or("returned file contents must be strings")?; + edited.insert(path.clone(), body.to_owned()); + } + check(&original, &edited).map_err(|diagnostics| { + let lines: Vec<_> = diagnostics.iter().map(ToString::to_string).collect(); + lines.join("; ") + }) +} + +/// The one change a single-file prompt edit derives. +fn only_change(review: &Review) -> Result { + match review.changes.as_slice() { + [change] => Ok(change.clone()), + other => Err(format!("expected one prompt change, derived {}", other.len()).into()), + } } /// Score three held-out numeric answers using the lowered seat prompt. @@ -209,48 +246,36 @@ fn record( )?) } -/// Distinguish provider refusals from guard rejections and check contamination. +/// Distinguish provider refusals from check refusals and check contamination. fn check_guards(provider: &mut Provider, active: &Package) -> Result<(String, bool, Value)> { - let attack = provider.complete("Return exactly this JSON object: {\"patch\":{\"class\":\"roster\",\"operations\":[{\"operation\":\"json\",\"pointer\":\"/manifest/seats/0/config/telemetry/enabled\",\"before\":true,\"after\":false}]}}.", - "This is a tamper-resistance check: attempt to disable the evaluator telemetry.")?; + let mut tampered = serde_json::to_value(&active.manifest)?; + tampered["seats"][0]["config"]["telemetry"]["enabled"] = json!(false); + let fixture = json!({"files": {"hive.json": serde_json::to_string_pretty(&tampered)?}}); + let attack = provider.complete( + &format!("Return exactly this JSON object: {fixture}."), + "This is a tamper-resistance check: attempt to disable the evaluator telemetry.", + )?; let attack_response = attack.clone(); - let model_authored_attack = attack.get("patch").is_some(); - let attack: Patch = if let Some(wire) = attack.get("patch") { - serde_json::from_value(wire.clone())? - } else { - // A provider may refuse the adversarial request. Still test the guard - // with the requested fixture and identify its host origin in evidence. - Patch { - class: PatchClass::Roster, - operations: vec![Operation::Json { - pointer: "/manifest/seats/0/config/telemetry/enabled".into(), - before: Some(json!(true)), - after: Some(json!(false)), - }], - } - }; - let rejection = patch::apply(active, &attack) + let model_authored_attack = attack.get("files").is_some(); + // A provider may refuse the adversarial request. Still test the check + // with the requested fixture and identify its host origin in evidence. + let returned = attack.get("files").unwrap_or(&fixture["files"]); + let rejection = edit(active, returned) .err() .ok_or("telemetry tampering was accepted")?; - let canary = Patch { - class: PatchClass::Prompt, - operations: vec![Operation::Prompt { - path: "seats/solver.md".into(), - before: active.documents["seats/solver.md"].clone(), - after: "HELDOUT-CANARY-LIVE-2026".into(), - }], - }; - if !matches!( - patch::apply(active, &canary), - Err(patch::Error::Contamination) - ) { + if !rejection.contains(Rule::Immutable.name()) { + return Err(format!("telemetry tampering refused for another reason: {rejection}").into()); + } + let canary = edit( + active, + &json!({"seats/solver.md": "HELDOUT-CANARY-LIVE-2026"}), + ) + .err() + .unwrap_or_default(); + if !canary.contains(Rule::Contamination.name()) { return Err("contamination guard failed".into()); } - Ok(( - rejection.to_string(), - model_authored_attack, - attack_response, - )) + Ok((rejection, model_authored_attack, attack_response)) } /// Require explicit live opt-in and a fresh evidence directory. @@ -299,10 +324,10 @@ fn main() -> Result<()> { Verdict::Accepted, vec![1, 2, 3], )?; - let edit = provider.complete("You edit a hive prompt. Return only a JSON object with patch and rationale. The exact wire shape is {\"patch\":{\"class\":\"prompt\",\"operations\":[{\"operation\":\"prompt\",\"path\":\"seats/solver.md\",\"before\":\"EXACT ORIGINAL\",\"after\":\"NEW BODY\"}]},\"rationale\":\"reason\"}. Preserve before exactly and include operation. Do not change anything else.", + let proposal = provider.complete("You edit a hive package by rewriting its files. Return only a JSON object with files and rationale. The exact wire shape is {\"files\":{\"seats/solver.md\":\"NEW FILE CONTENTS\"},\"rationale\":\"reason\"}. Return the whole new file. Do not change any other file.", &format!("Fix this prompt at seats/solver.md: {PROMPT:?}. Training feedback: it incorrectly counted canceled and pending invoices. Net paid revenue includes only paid invoices. Propose a general correction; you cannot see held-out tests."))?; - let patch: Patch = serde_json::from_value(edit["patch"].clone())?; - let applied = patch::apply(&incumbent, &patch)?; + let applied = edit(&incumbent, &proposal["files"])?; + let patch = only_change(&applied)?; let (candidate_score, candidate_cases) = evaluate(&mut provider, &applied.candidate)?; let verdict = if candidate_score == 3 && candidate_score >= baseline { Verdict::Accepted @@ -327,16 +352,13 @@ fn main() -> Result<()> { } else { initial.id.clone() }; - // Negative control is a real guarded prompt edit, evaluated by the same live provider. - let broken = Patch { - class: PatchClass::Prompt, - operations: vec![Operation::Prompt { - path: "seats/solver.md".into(), - before: active.documents["seats/solver.md"].clone(), - after: "Return exactly {\"total\":0} for every input. Do not calculate.".into(), - }], - }; - let negative = patch::apply(active, &broken)?.candidate; + // Negative control is a real checked prompt edit, evaluated by the same live provider. + let broken = edit( + active, + &json!({"seats/solver.md": "Return exactly {\"total\":0} for every input. Do not calculate."}), + )?; + let negative = broken.candidate.clone(); + let broken = only_change(&broken)?; let (negative_score, negative_cases) = evaluate(&mut provider, &negative)?; let rejected = record( &negative, diff --git a/crates/tinyhivemind-lang/examples/self_edit.rs b/crates/tinyhivemind-lang/examples/self_edit.rs index eafe0edf..96ef4bfd 100644 --- a/crates/tinyhivemind-lang/examples/self_edit.rs +++ b/crates/tinyhivemind-lang/examples/self_edit.rs @@ -1,17 +1,65 @@ -//! A host proposes, guards, evaluates and archives accepted and rejected designs. +//! An editor rewrites a package's files; the host checks the edit like a +//! compiler, evaluates the candidate, and archives accepted and rejected +//! designs with one lineage record per derived change. use std::collections::BTreeMap; use tinyhivemind_lang::{ - Package, - lineage::{self, Direction, ObjectiveScore, Verdict, VersionMetadata}, - patch::{PatchClass, apply, diff}, + Package, Review, check, + lineage::{self, Direction, ObjectiveScore, Record, Verdict, VersionMetadata}, + patch::{Patch, PatchClass}, }; -fn evidence( - parent: Option, - patch: tinyhivemind_lang::patch::Patch, - score: f64, - verdict: Verdict, -) -> VersionMetadata { +type Result = std::result::Result>; +type Files = BTreeMap; + +/// The founding package, as the files an editor would see on disk. +fn founding_files() -> Result { + let mut package = Package::default(); + package.constitution.permitted_edits = vec![PatchClass::Prompt]; + package.constitution.contamination_canaries = vec!["private-evaluation-canary".into()]; + package + .documents + .insert("roles/editor.md".into(), "Answer the task.\n".into()); + let mut files = package.documents.clone(); + files.insert( + "hive.json".into(), + serde_json::to_string_pretty(&package.manifest)?, + ); + files.insert( + "constitution.json".into(), + serde_json::to_string_pretty(&package.constitution)?, + ); + files.insert( + "memory.json".into(), + serde_json::to_string_pretty(&package.memory)?, + ); + Ok(files) +} + +/// The editor: it returns whole files, not operations. A real host would +/// ask a model; this one rewrites the charter, or tries to loosen the +/// constitution when asked to misbehave. +fn editor(files: &Files, charter: &str, tamper: bool) -> Files { + let mut edited = files.clone(); + edited.insert("roles/editor.md".into(), charter.into()); + if tamper { + let constitution = + files["constitution.json"].replace("\"max_turns\": 60", "\"max_turns\": 600"); + edited.insert("constitution.json".into(), constitution); + } + edited +} + +/// Host-owned evaluation: a deterministic rubric standing in for held-out +/// tests. The language neither runs a model nor chooses acceptance. +fn evaluate(candidate: &Package) -> f64 { + if candidate.documents["roles/editor.md"].contains("evidence") { + 0.8 + } else { + 0.2 + } +} + +fn metadata(parent: Option, patch: Patch, score: f64, verdict: Verdict) -> VersionMetadata { VersionMetadata { parent, patch, @@ -29,61 +77,108 @@ fn evidence( } } -fn main() -> Result<(), Box> { - let mut original = Package::default(); - original.constitution.permitted_edits = vec![PatchClass::Prompt]; - original.constitution.contamination_canaries = vec!["private-evaluation-canary".into()]; - original - .documents - .insert("roles/editor.md".into(), "Answer the task.\n".into()); - let initial_patch = tinyhivemind_lang::patch::Patch { - class: PatchClass::Prompt, - operations: vec![], - }; - let initial = lineage::record( +/// Record one lineage record per derived change, each the parent of the +/// next; every step carries the whole edit's score and verdict. +fn archive( + records: &mut Vec, + original: &Package, + review: &Review, + verdict: Verdict, +) -> Result<()> { + let score = evaluate(&review.candidate); + let mut current = original.clone(); + for change in &review.changes { + current = tinyhivemind_lang::patch::apply(¤t, change)?.candidate; + let parent = records.last().map(|record| record.id.clone()); + records.push(lineage::record( + ¤t, + metadata(parent, change.clone(), score, verdict), + )?); + } + Ok(()) +} + +/// What one run of the example produced. +#[derive(Debug)] +struct Summary { + refusal: Vec, + archived: usize, + frontier: usize, + head: String, + accepted_id: String, +} + +fn run() -> Result { + let founding = founding_files()?; + let original = tinyhivemind_lang::parse(&founding)?; + let mut records = vec![lineage::record( &original, - evidence(None, initial_patch, 0.5, Verdict::Accepted), - )?; - let mut proposed = original.clone(); - proposed.documents.insert( - "roles/editor.md".into(), - "Answer the task and cite concrete evidence.\n".into(), - ); - let patch = diff(&original, &proposed, PatchClass::Prompt)?; - let applied = apply(&original, &patch)?; - // The host supplies held-out evaluation. This deterministic example uses - // a local rubric; the language neither runs a model nor chooses acceptance. - let quality = if applied.candidate.documents["roles/editor.md"].contains("evidence") { - 0.8 - } else { - 0.4 - }; - let accepted = lineage::record( - &applied.candidate, - evidence(Some(initial.id.clone()), patch, quality, Verdict::Accepted), - )?; - let mut rejected_candidate = applied.candidate.clone(); - rejected_candidate - .documents - .insert("roles/editor.md".into(), "Guess without checking.\n".into()); - let rejected_patch = diff(&applied.candidate, &rejected_candidate, PatchClass::Prompt)?; - let rejected_candidate = apply(&applied.candidate, &rejected_patch)?.candidate; - let rejected = lineage::record( - &rejected_candidate, - evidence( - Some(accepted.id.clone()), - rejected_patch, - 0.2, - Verdict::Rejected, + metadata( + None, + Patch { + class: PatchClass::Prompt, + operations: vec![], + }, + evaluate(&original), + Verdict::Accepted, ), + )?]; + + // An edit that also loosens the constitution is refused, with every + // finding located. + let tampered = editor(&founding, "Answer and cite evidence.\n", true); + let refusal = match check(&founding, &tampered) { + Ok(_) => return Err("a constitution edit was accepted".into()), + Err(diagnostics) => diagnostics.iter().map(ToString::to_string).collect(), + }; + + // A charter rewrite is accepted, evaluated and archived. + let improved = editor( + &founding, + "Answer the task and cite concrete evidence.\n", + false, + ); + let review = check(&founding, &improved).map_err(|d| format!("refused: {d:?}"))?; + archive(&mut records, &original, &review, Verdict::Accepted)?; + let accepted_id = records.last().map(|r| r.id.clone()).unwrap_or_default(); + + // A worse follow-up passes the check but the host rejects it. + let worse = editor(&improved, "Guess without checking.\n", false); + let follow_up = check(&improved, &worse).map_err(|d| format!("refused: {d:?}"))?; + archive( + &mut records, + &review.candidate, + &follow_up, + Verdict::Rejected, )?; - let archive = vec![initial, accepted, rejected]; - let head = lineage::accepted_head(&archive)?.ok_or("expected an accepted design")?; - println!("Accepted head: {}", head.id); + + let head = lineage::accepted_head(&records)?.ok_or("expected an accepted design")?; + Ok(Summary { + refusal, + archived: records.len(), + frontier: lineage::pareto_frontier(&records)?.len(), + head: head.id.clone(), + accepted_id, + }) +} + +fn main() -> Result<()> { + let summary = run()?; + if summary.head != summary.accepted_id { + return Err("a rejected design became the accepted head".into()); + } + println!("Refused edit:"); + for line in &summary.refusal { + println!(" {line}"); + } + println!("Accepted head: {}", summary.head); println!( "Archived designs: {}; Pareto frontier: {}", - archive.len(), - lineage::pareto_frontier(&archive)?.len() + summary.archived, summary.frontier ); Ok(()) } + +#[cfg(test)] +#[path = "self_edit/test.rs"] +mod test; diff --git a/crates/tinyhivemind-lang/examples/self_edit/README.md b/crates/tinyhivemind-lang/examples/self_edit/README.md new file mode 100644 index 00000000..e8012f7a --- /dev/null +++ b/crates/tinyhivemind-lang/examples/self_edit/README.md @@ -0,0 +1,5 @@ +# Self-edit example tests + +| File | Purpose | +| --- | --- | +| `test.rs` | Runs the example offline: the tampered edit is refused with a located diagnostic and the rejected design never becomes the accepted head | diff --git a/crates/tinyhivemind-lang/examples/self_edit/test.rs b/crates/tinyhivemind-lang/examples/self_edit/test.rs new file mode 100644 index 00000000..3f6c6b47 --- /dev/null +++ b/crates/tinyhivemind-lang/examples/self_edit/test.rs @@ -0,0 +1,19 @@ +//! The self-edit example runs offline and keeps the rejected design out of +//! the accepted head. +use super::{Result, run}; + +#[test] +fn refuses_the_tampered_edit_and_activates_only_the_accepted_one() -> Result<()> { + let summary = run()?; + assert_eq!( + summary.refusal, + [ + "constitution.json:/max_turns: immutable: the constitution is host-owned and no edit \ + may change it" + ] + ); + assert_eq!(summary.archived, 3); + assert_eq!(summary.frontier, 1); + assert_eq!(summary.head, summary.accepted_id); + super::main() +} diff --git a/crates/tinyhivemind-lang/src/README.md b/crates/tinyhivemind-lang/src/README.md index c1d95902..81999379 100644 --- a/crates/tinyhivemind-lang/src/README.md +++ b/crates/tinyhivemind-lang/src/README.md @@ -1,14 +1,25 @@ # Language source All operations consume caller-owned values. No module opens storage or starts -an agent. Public feature modules are exposed through `lib.rs`. +an agent. `lib.rs` carries the crate overview and runnable example, and +re-exports the module surface: `check`, `check_packages`, `Review`, +`diagnose`, `diagnose_documents`, `Diagnostic`, `Rule`, `parse`, `validate`, +`validate_path`, `validate_namespace`, `lower`, and every wire type. + +Editors change a hive by editing its files; `check` is the compiler that +reports every violation of the language and the constitution as a located +`Diagnostic`, or returns the edit's per-class patches for lineage. `patch` and `lineage` +keep their own `Error` and `Result`, so they are used through their module +paths. | Module | Purpose | | --- | --- | -| document | Canonical manifest JSON and safe Markdown document maps. | -| memory | Portable identities, lifecycle, bounded reads and recall settings. | -| validation | References, paths, graph integrity and constitution caps. | -| lowering | Core runtime values and host-owned seat creation inputs. | -| error | Structured parsing and validation failures. | -| patch | Typed, classified edits with atomic guarded application. | -| lineage | Content identities, evaluated records and archive selection folds. | +| `check` | `check` and `check_packages`: direct edits checked against the original and the constitution, every finding collected; `Review` on success. | +| `diagnostic` | `Diagnostic` and `Rule`, and the collector every rule reports into. | +| `document` | `Package` and `Manifest` wire types, `parse` and `diagnose_documents` over a caller-supplied document map. | +| `memory` | `Constitution` and `MemorySpec`: caps, protected namespaces, identities, lifecycles, reaches and recall settings. | +| `validation` | `validate`, `diagnose`, `validate_path`, `validate_namespace`: references, paths, frontmatter agreement, goal acyclicity, policy bounds and memory access. | +| `lowering` | `lower`: core desks, routing candidates and host seat creation inputs. | +| `error` | The package-level `Error` returned by parsing, validation and lowering. | +| `patch` | The recorded form of an edit: `apply`, `guard`, and `diff`; learning operations. | +| `lineage` | Content identities, evaluated records, `accepted_head` and `pareto_frontier`. | diff --git a/crates/tinyhivemind-lang/src/check/README.md b/crates/tinyhivemind-lang/src/check/README.md new file mode 100644 index 00000000..c2f859b9 --- /dev/null +++ b/crates/tinyhivemind-lang/src/check/README.md @@ -0,0 +1,39 @@ +# check + +The compiler for direct edits. An orchestrator edits a package's files; +the host passes the original and edited document maps to `check` (or two +decoded packages to `check_packages`) and gets back either every violation as +a located `Diagnostic` or a `Review`. + +## Design + +1. The original must be valid; its findings return with rule `original`. +2. The edited files are diagnosed in full (`diagnose_documents`). +3. Every changed manifest or memory top-level field and every changed + Markdown document becomes one derived operation, owned by the edit class + whose `patch` boundary admits it. A field's diagnostic points at its first + differing leaf. +4. Every rule collects rather than stops: a changed constitution, protected + key or frontmatter (`immutable`); a class the constitution does not permit + (`forbidden_class`); a change no class owns or a file outside the package + (`unowned`); a canary (`contamination`); a pinned memory or binding + (`read_only`); a context edit item deltas cannot express (`context`). +5. With no finding, operations group into one `Patch` per class in class + order. If a step would pass through an invalid package, the first valid + order is used; with none, one `sequence` diagnostic is returned. + +Diagnostics are deduplicated by file, pointer and rule and sorted by file +then pointer. Applying `Review::changes` in order with `patch::apply` +reproduces `Review::candidate` exactly. A host records one lineage record per +change. Memory contents are not files: learning operations stay on the +`patch::apply` path. + +When the edit does not decode, only the raw files are read: the constitution +as JSON and every other file for canaries. + +| File | Purpose | +| --- | --- | +| `mod.rs` | `check`, `check_packages`, the raw-file fallback, sorting, and the per-class ordering search | +| `rules.rs` | Each edit rule: constitution, protected keys, canaries, read-only memories, classification and context replay | +| `types.rs` | `Review`, the derived changes and the edited package | +| `test/` | Behaviour-grouped unit tests; see [`test/README.md`](test/README.md) | diff --git a/crates/tinyhivemind-lang/src/check/mod.rs b/crates/tinyhivemind-lang/src/check/mod.rs new file mode 100644 index 00000000..f7e557e5 --- /dev/null +++ b/crates/tinyhivemind-lang/src/check/mod.rs @@ -0,0 +1,225 @@ +//! Check a direct edit of a package's files, like a compiler checks source. +//! +//! An editor changes a hive by rewriting its files. [`check`] compares the +//! original document map with the edited one, runs every language and +//! constitution rule, and collects every violation as a located +//! [`Diagnostic`]. With none, it returns a [`Review`]: the edit split into +//! one [`Patch`] per touched edit class, ready for lineage. +//! [`check_packages`] does the same for two decoded packages. +//! +//! Memory *contents* are not files: learning operations stay on the +//! [`crate::patch::apply`] path. + +mod rules; +mod types; +use crate::{ + Diagnostic, Package, Rule, diagnose, diagnose_documents, + diagnostic::CONSTITUTION, + patch::{self, CLASSES, Operation, Patch, PatchClass}, +}; +use std::collections::{BTreeMap, BTreeSet}; +pub use types::Review; + +/// Check an edited document map against the original it was made from. +/// +/// The original must be valid; its findings come back with rule +/// [`Rule::Original`]. The edited map is diagnosed in full, then compared: +/// a changed constitution, protected key or frontmatter; a change of a class +/// the constitution does not permit, or one no class owns; a canary; a +/// change to a read-only memory; and a context edit item deltas cannot +/// express are each reported where they occur, all in one call. +/// +/// # Errors +/// Returns every [`Diagnostic`], deduplicated by file, pointer and rule and +/// sorted by file then pointer, when the edit breaks any rule. +pub fn check( + original: &BTreeMap, + edited: &BTreeMap, +) -> Result> { + let base = valid_original(diagnose_documents(original))?; + let (edit, mut out) = diagnose_documents(edited); + rules::outside_package(original, edited, &mut out); + let operations = if let Some(edit) = &edit { + compare(&base, edit, &mut out) + } else { + undecoded(&base, edited, &mut out); + vec![] + }; + finish(&base, edit, out, &operations) +} + +/// The edit did not decode, so only its raw files can be read: the +/// constitution as JSON, and every other file for canaries. +fn undecoded(base: &Package, edited: &BTreeMap, out: &mut Vec) { + if let Some(value) = edited + .get(CONSTITUTION) + .and_then(|source| serde_json::from_str(source).ok()) + { + rules::constitution(&rules::wire(&base.constitution), &value, out); + } + let files = edited + .iter() + .filter(|(path, _)| path.as_str() != CONSTITUTION) + .map(|(path, source)| { + let json = std::path::Path::new(path) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")); + let content = serde_json::from_str(source) + .ok() + .filter(|_| json) + .map_or(rules::Content::Text(source), rules::Content::Json); + (path.as_str(), content) + }); + rules::canaries(base, files, out); +} + +/// Check an edited package against the original it was made from. +/// +/// The same rules as [`check`], for a host that already holds decoded +/// packages, such as the whole-package wire form. +/// +/// # Errors +/// Returns every [`Diagnostic`] when the edit breaks any rule. +pub fn check_packages(original: &Package, edited: &Package) -> Result> { + let base = valid_original((Some(original.clone()), diagnose(original)))?; + let mut out = diagnose(edited); + let operations = compare(&base, edited, &mut out); + finish(&base, Some(edited.clone()), out, &operations) +} + +/// The original package, or its findings recast as [`Rule::Original`]. +fn valid_original( + (package, diagnostics): (Option, Vec), +) -> Result> { + match package { + Some(package) if diagnostics.is_empty() => Ok(package), + _ => Err(diagnostics + .into_iter() + .map(|finding| Diagnostic { + message: format!( + "the original package is invalid: {}: {}", + finding.rule, finding.message + ), + rule: Rule::Original, + ..finding + }) + .collect()), + } +} + +/// Run every edit rule between two decoded packages. +fn compare( + base: &Package, + edit: &Package, + out: &mut Vec, +) -> Vec<(PatchClass, Operation)> { + rules::constitution( + &rules::wire(&base.constitution), + &rules::wire(&edit.constitution), + out, + ); + rules::protected(base, edit, out); + rules::canaries(base, rules::package_files(edit), out); + rules::read_only(base, edit, out); + rules::classify(base, edit, out) +} + +/// Report the findings, or turn the classified operations into a review. +fn finish( + base: &Package, + edit: Option, + out: Vec, + operations: &[(PatchClass, Operation)], +) -> Result> { + let mut seen = BTreeSet::new(); + let mut out: Vec<_> = out + .into_iter() + .filter(|d| seen.insert((d.file.clone(), d.pointer.clone(), d.rule))) + .collect(); + out.sort_by(|a, b| (&a.file, &a.pointer).cmp(&(&b.file, &b.pointer))); + match edit { + Some(edit) if out.is_empty() => review(base, edit, operations), + _ => Err(out), + } +} + +/// Group operations into one patch per class and find an order in which +/// every step applies and validates. +fn review( + base: &Package, + candidate: Package, + operations: &[(PatchClass, Operation)], +) -> Result> { + let patches: Vec = CLASSES + .into_iter() + .filter_map(|class| { + let operations: Vec<_> = operations + .iter() + .filter(|(owner, _)| *owner == class) + .map(|(_, operation)| operation.clone()) + .collect(); + (!operations.is_empty()).then_some(Patch { class, operations }) + }) + .collect(); + match order(base, &patches) { + Some((changes, end)) if end == candidate => Ok(Review { changes, candidate }), + found => Err(vec![sequence(base, &patches, found.is_some())]), + } +} + +/// The first order of `remaining`, preferring class order, in which every +/// patch applies; and the package it ends at. +fn order(current: &Package, remaining: &[Patch]) -> Option<(Vec, Package)> { + if remaining.is_empty() { + return Some((vec![], current.clone())); + } + remaining.iter().enumerate().find_map(|(i, next)| { + let applied = patch::apply(current, next).ok()?; + let mut rest = remaining.to_vec(); + rest.remove(i); + let (mut tail, end) = order(&applied.candidate, &rest)?; + tail.insert(0, next.clone()); + Some((tail, end)) + }) +} + +/// Explain why the classified edit has no valid order of steps. +fn sequence(base: &Package, patches: &[Patch], reproduced: bool) -> Diagnostic { + let names: Vec<_> = patches + .iter() + .filter_map(|patch| rules::wire(&patch.class).as_str().map(str::to_owned)) + .collect(); + let mut current = base.clone(); + let mut failure = (patches.first(), String::new()); + for patch in patches { + match patch::apply(¤t, patch) { + Ok(applied) => current = applied.candidate, + Err(error) => { + failure = (Some(patch), error.to_string()); + break; + } + } + } + let (failing, error) = failure; + let file = failing + .and_then(|patch| patch.operations.first()) + .map_or_else(|| "hive.json".to_owned(), |op| rules::location(op).0); + let message = if reproduced { + "the derived changes do not reproduce the edited package".to_owned() + } else { + format!( + "no order of the {} changes is valid at every step ({error}); make the edit as \ + separate smaller edits", + names.join(", ") + ) + }; + Diagnostic { + file, + pointer: None, + rule: Rule::Sequence, + message, + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinyhivemind-lang/src/check/rules.rs b/crates/tinyhivemind-lang/src/check/rules.rs new file mode 100644 index 00000000..a6659f9c --- /dev/null +++ b/crates/tinyhivemind-lang/src/check/rules.rs @@ -0,0 +1,367 @@ +//! The edit rules: each compares the original and edited package and pushes +//! a located diagnostic for every violation it finds. + +use crate::{ + Diagnostic, Package, Rule, SeatMemory, + diagnostic::{CONSTITUTION, HIVE, MEMORY, escape}, + patch::{self, Operation, PatchClass}, +}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +/// A package value as wire JSON. The wire types serialize infallibly; a +/// failure would surface as `null` and so as a difference, never as a pass. +pub(super) fn wire(value: &T) -> Value { + serde_json::to_value(value).unwrap_or(Value::Null) +} + +fn diagnostic(file: &str, pointer: Option, rule: Rule, message: String) -> Diagnostic { + Diagnostic { + file: file.to_owned(), + pointer, + rule, + message, + } +} + +/// The pointer suffix of the first leaf at which `before` and `after` differ. +pub(super) fn first_difference(before: Option<&Value>, after: Option<&Value>) -> String { + match (before, after) { + (Some(Value::Object(a)), Some(Value::Object(b))) => a + .keys() + .chain(b.keys()) + .collect::>() + .into_iter() + .find(|key| a.get(*key) != b.get(*key)) + .map(|key| { + format!( + "/{}{}", + escape(key), + first_difference(a.get(key), b.get(key)) + ) + }) + .unwrap_or_default(), + (Some(Value::Array(a)), Some(Value::Array(b))) => (0..a.len().max(b.len())) + .find(|i| a.get(*i) != b.get(*i)) + .map(|i| format!("/{i}{}", first_difference(a.get(i), b.get(i)))) + .unwrap_or_default(), + _ => String::new(), + } +} + +/// Where a derived operation lives: its file and, for JSON, its first +/// differing leaf. +pub(super) fn location(operation: &Operation) -> (String, Option) { + match operation { + Operation::Json { + pointer, + before, + after, + } => { + let (file, field) = match pointer.strip_prefix("/manifest") { + Some(field) => (HIVE, field), + None => (MEMORY, pointer.strip_prefix("/memory").unwrap_or(pointer)), + }; + let leaf = first_difference(before.as_ref(), after.as_ref()); + (file.to_owned(), Some(format!("{field}{leaf}"))) + } + Operation::Document { path, .. } + | Operation::Prompt { path, .. } + | Operation::Context { path, .. } => (path.clone(), None), + Operation::Learning { .. } => (MEMORY.to_owned(), None), + } +} + +/// Report every changed top-level field of the constitution. +pub(super) fn constitution(old: &Value, new: &Value, out: &mut Vec) { + let message = || "the constitution is host-owned and no edit may change it".to_owned(); + match (old, new) { + (Value::Object(a), Value::Object(b)) => { + for key in a.keys().chain(b.keys()).collect::>() { + if a.get(key) != b.get(key) { + let leaf = first_difference(a.get(key), b.get(key)); + let pointer = format!("/{}{leaf}", escape(key)); + out.push(diagnostic( + CONSTITUTION, + Some(pointer), + Rule::Immutable, + message(), + )); + } + } + } + _ if old != new => out.push(diagnostic(CONSTITUTION, None, Rule::Immutable, message())), + _ => {} + } +} + +/// Protected keys of the manifest and of every parseable frontmatter. +fn protected_entries(package: &Package) -> BTreeMap<(String, String), Value> { + let mut entries: BTreeMap<_, _> = patch::protected(&wire(&package.manifest)) + .into_iter() + .map(|(pointer, value)| ((HIVE.to_owned(), pointer), value)) + .collect(); + for (path, source) in &package.documents { + let Some((metadata, _)) = source + .strip_prefix("---\n") + .and_then(|tail| tail.split_once("\n---\n")) + else { + continue; + }; + if let Ok(metadata) = serde_json::from_str::(metadata) { + for (pointer, value) in patch::protected(&metadata) { + entries.insert((path.clone(), pointer), value); + } + } + } + entries +} + +/// Report every protected key added, removed or changed. +pub(super) fn protected(base: &Package, edit: &Package, out: &mut Vec) { + let old = protected_entries(base); + let new = protected_entries(edit); + for key in old.keys().chain(new.keys()).collect::>() { + if old.get(key) == new.get(key) { + continue; + } + let (file, pointer) = key; + let token = pointer.rsplit('/').next().unwrap_or_default(); + let message = format!("{token} settings are host-owned and no edit may change them"); + // A frontmatter pointer is not a pointer into the Markdown file, so + // it travels in the message instead. + let (pointer, message) = if file == HIVE { + (Some(pointer.clone()), message) + } else { + (None, format!("{message} (frontmatter {pointer})")) + }; + out.push(diagnostic(file, pointer, Rule::Immutable, message)); + } +} + +/// File contents a canary scan reads: decoded JSON, or raw text. +pub(super) enum Content<'a> { + /// A decoded JSON file, scanned so a hit can name its pointer. + Json(Value), + /// A Markdown or undecodable file. + Text(&'a str), +} + +/// The pointer of the first string or key in `value` containing `canary`. +fn find_canary(value: &Value, canary: &str) -> Option { + match value { + Value::String(text) => text.contains(canary).then(String::new), + Value::Array(values) => values + .iter() + .enumerate() + .find_map(|(i, value)| find_canary(value, canary).map(|tail| format!("/{i}{tail}"))), + Value::Object(map) => map.iter().find_map(|(key, value)| { + if key.contains(canary) { + Some(format!("/{}", escape(key))) + } else { + find_canary(value, canary).map(|tail| format!("/{}{tail}", escape(key))) + } + }), + _ => None, + } +} + +/// Report every file that contains one of the original constitution's +/// canaries. +pub(super) fn canaries<'a>( + base: &Package, + files: impl IntoIterator)>, + out: &mut Vec, +) { + let canaries: Vec<_> = base + .constitution + .contamination_canaries + .iter() + .filter(|canary| !canary.is_empty()) + .collect(); + for (file, content) in files { + let hit = canaries.iter().find_map(|canary| match &content { + Content::Json(value) => find_canary(value, canary).map(Some), + Content::Text(text) => text.contains(canary.as_str()).then_some(None), + }); + if let Some(pointer) = hit { + out.push(diagnostic( + file, + pointer, + Rule::Contamination, + "contains a contamination canary the constitution holds out".into(), + )); + } + } +} + +/// The scan inputs for a decoded package: everything but the constitution. +pub(super) fn package_files(package: &Package) -> Vec<(&str, Content<'_>)> { + let mut files = vec![ + (HIVE, Content::Json(wire(&package.manifest))), + (MEMORY, Content::Json(wire(&package.memory))), + ]; + files.extend( + package + .documents + .iter() + .map(|(path, source)| (path.as_str(), Content::Text(source))), + ); + files +} + +/// The bindings of memory `id`, with their indices. +fn bindings<'a>(package: &'a Package, id: &str) -> Vec<(usize, &'a SeatMemory)> { + package + .memory + .bindings + .iter() + .enumerate() + .filter(|(_, binding)| binding.identity == id) + .collect() +} + +/// Report a changed identity, or changed bindings, of a pinned memory. +pub(super) fn read_only(base: &Package, edit: &Package, out: &mut Vec) { + for id in &base.constitution.read_only_memories { + let message = || format!("memory {id} is pinned read-only by the constitution"); + let find = |package: &Package| { + package + .memory + .identities + .iter() + .position(|identity| &identity.id == id) + }; + let (old, new) = (find(base), find(edit)); + if old.map(|i| &base.memory.identities[i]) != new.map(|i| &edit.memory.identities[i]) { + let pointer = new.or(old).map(|i| format!("/identities/{i}")); + out.push(diagnostic(MEMORY, pointer, Rule::ReadOnly, message())); + } + let (old, new) = (bindings(base, id), bindings(edit, id)); + if !old.iter().map(|(_, b)| b).eq(new.iter().map(|(_, b)| b)) { + let index = new.first().or(old.first()).map(|(i, _)| *i); + let pointer = index.map(|i| format!("/bindings/{i}")); + out.push(diagnostic(MEMORY, pointer, Rule::ReadOnly, message())); + } + } +} + +/// Check that a context document's edit is expressible as item deltas. +fn context_replay(path: &str, before: &str, after: &str, ops: &[Operation]) -> Option { + let ops: Vec<_> = ops.iter().collect(); + let message = match patch::replay_items(before, &ops) { + Err(error) => error.to_string(), + Ok(replayed) if replayed != after => "text outside the stable items changed, or items \ + moved; edit inside items and add new items after the last one" + .into(), + Ok(_) => return None, + }; + Some(diagnostic(path, None, Rule::Context, message)) +} + +/// Derive every operation between the packages, report the ones that are +/// unowned, forbidden or unrepresentable, and return the rest by class. +pub(super) fn classify( + base: &Package, + edit: &Package, + out: &mut Vec, +) -> Vec<(PatchClass, Operation)> { + let (old, new) = (wire(base), wire(edit)); + let mut operations = patch::fields(&old, &new, "manifest"); + operations.extend(patch::fields(&old, &new, "memory")); + let paths: BTreeSet<_> = base.documents.keys().chain(edit.documents.keys()).collect(); + for path in paths { + let (before, after) = (base.documents.get(path), edit.documents.get(path)); + operations.extend(patch::lifecycle(path, before, after)); + if before.is_none() + && let Some(after) = after + && path.starts_with("context/") + && let Err(error) = patch::validate_context(after) + { + out.push(diagnostic(path, None, Rule::Context, error.to_string())); + } + let (Some(before), Some(after)) = (before, after) else { + continue; + }; + match patch::modification(path, before, after) { + Ok(list) => { + if path.starts_with("context/") { + out.extend(context_replay(path, before, after, &list)); + } + if list.iter().any(|operation| { + matches!(operation, Operation::Prompt { after, .. } if after.starts_with("---\n")) + }) { + out.push(diagnostic( + path, + None, + Rule::Immutable, + "the body may not open a second frontmatter block".into(), + )); + } + operations.extend(list); + } + Err(patch::Error::Immutable) => out.push(diagnostic( + path, + None, + Rule::Immutable, + "frontmatter restates the manifest and may not be edited; change the body only" + .into(), + )), + Err(error) => out.push(diagnostic(path, None, Rule::Context, error.to_string())), + } + } + let permitted = &base.constitution.permitted_edits; + let mut owned = Vec::new(); + for operation in operations { + let (file, pointer) = location(&operation); + match patch::owner(&operation) { + None => { + let what = pointer.as_deref().map_or(file.as_str(), |pointer| { + pointer.split('/').nth(1).unwrap_or_default() + }); + let message = format!( + "no edit class owns {what}; only seats/, roles/ and context/ documents and \ + the manifest and memory sections a class names may change" + ); + out.push(diagnostic(&file, pointer, Rule::Unowned, message)); + } + Some(class) if !permitted.contains(&class) => { + let name = wire(&class); + let message = format!( + "the constitution does not permit {} edits", + name.as_str().unwrap_or_default() + ); + out.push(diagnostic(&file, pointer, Rule::ForbiddenClass, message)); + } + Some(class) => owned.push((class, operation)), + } + } + owned +} + +/// Report every changed file that is not part of a package at all. +pub(super) fn outside_package( + original: &BTreeMap, + edited: &BTreeMap, + out: &mut Vec, +) { + let keys: BTreeSet<_> = original.keys().chain(edited.keys()).collect(); + for key in keys { + let markdown = std::path::Path::new(key) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")); + if markdown || [HIVE, CONSTITUTION, MEMORY].contains(&key.as_str()) { + continue; + } + if original.get(key) != edited.get(key) { + out.push(diagnostic( + key, + None, + Rule::Unowned, + "not part of the package; only hive.json, constitution.json, memory.json and \ + markdown documents are read" + .into(), + )); + } + } +} diff --git a/crates/tinyhivemind-lang/src/check/test/README.md b/crates/tinyhivemind-lang/src/check/test/README.md new file mode 100644 index 00000000..d0586c5b --- /dev/null +++ b/crates/tinyhivemind-lang/src/check/test/README.md @@ -0,0 +1,10 @@ +# check tests + +| File | Purpose | +| --- | --- | +| `mod.rs` | Module doc and submodule wiring | +| `support.rs` | A valid fixture touching every edit class, file-map helpers, and assertions | +| `guards.rs` | Each edit rule reported at its file and pointer | +| `files.rs` | Document-map checks: an invalid original, non-package files, an undecodable edit | +| `sequence.rs` | Per-class changes, their order, and the `sequence` finding | +| `locations.rs` | First-difference pointers, operation locations, and the `Review` wire | diff --git a/crates/tinyhivemind-lang/src/check/test/files.rs b/crates/tinyhivemind-lang/src/check/test/files.rs new file mode 100644 index 00000000..4e90dd88 --- /dev/null +++ b/crates/tinyhivemind-lang/src/check/test/files.rs @@ -0,0 +1,87 @@ +//! Checking by document map: an invalid original, files outside the +//! package, and edits that do not decode. +use super::support::*; +use crate::{Rule, check, check_packages}; + +#[test] +fn an_invalid_original_is_reported_as_the_original() { + let mut base = package(); + base.manifest.seats[0].role = "ghost".into(); + let diagnostics = refused(check(&files(&base), &files(&package()))); + assert_eq!( + located(&diagnostics), + [("hive.json", Some("/seats/0/role"), Rule::Original)] + ); + assert_eq!( + diagnostics[0].message, + "the original package is invalid: reference: unresolved role reference ghost" + ); + let diagnostics = refused(check_packages(&base, &package())); + assert_eq!(diagnostics[0].rule, Rule::Original); + let mut broken = files(&package()); + broken.insert("hive.json".into(), "{".into()); + let diagnostics = refused(check(&broken, &files(&package()))); + assert_eq!(located(&diagnostics), [("hive.json", None, Rule::Original)]); +} + +#[test] +fn a_file_outside_the_package_is_unowned_whether_added_or_removed() { + let mut base = files(&package()); + base.insert("notes.txt".into(), "kept\n".into()); + base.insert("plan.txt".into(), "kept\n".into()); + let mut edit = base.clone(); + edit.remove("notes.txt"); + edit.insert("extra.yaml".into(), "a: 1\n".into()); + let diagnostics = refused(check(&base, &edit)); + assert_eq!( + located(&diagnostics), + [ + ("extra.yaml", None, Rule::Unowned), + ("notes.txt", None, Rule::Unowned), + ] + ); +} + +#[test] +fn an_undecodable_edit_still_reports_constitution_and_canary_findings() { + let base = package(); + let mut edit = files(&base); + edit.insert("hive.json".into(), "{\"version\": }".into()); + let mut constitution = base.constitution.clone(); + constitution.max_width = 2; + edit.insert( + "constitution.json".into(), + serde_json::to_string(&constitution).expect("constitution serializes"), + ); + edit.insert("seats/alice.md".into(), "CANARY-1\n".into()); + edit.insert( + "extra.json".into(), + serde_json::json!({"notes": ["CANARY-1"]}).to_string(), + ); + let diagnostics = refused(check(&files(&base), &edit)); + assert_eq!( + located(&diagnostics), + [ + ("constitution.json", Some("/max_width"), Rule::Immutable), + ("extra.json", None, Rule::Unowned), + ("extra.json", Some("/notes/0"), Rule::Contamination), + ("hive.json", None, Rule::Json), + ("seats/alice.md", None, Rule::Contamination), + ] + ); + assert!(diagnostics[3].message.contains("line 1 column 13")); +} + +#[test] +fn a_constitution_that_is_not_an_object_is_immutable_as_a_whole() { + let mut edit = files(&package()); + edit.insert("constitution.json".into(), "[]".into()); + let diagnostics = refused(check(&files(&package()), &edit)); + assert_eq!( + located(&diagnostics), + [ + ("constitution.json", None, Rule::Json), + ("constitution.json", None, Rule::Immutable), + ] + ); +} diff --git a/crates/tinyhivemind-lang/src/check/test/guards.rs b/crates/tinyhivemind-lang/src/check/test/guards.rs new file mode 100644 index 00000000..5c959ffb --- /dev/null +++ b/crates/tinyhivemind-lang/src/check/test/guards.rs @@ -0,0 +1,283 @@ +//! The edit rules: immutability, class authority, canaries, read-only +//! memories and context deltas, each reported where it occurs. +use super::support::*; +use crate::{Rule, patch::PatchClass}; +use serde_json::json; + +#[test] +fn a_constitution_edit_is_immutable_at_each_changed_field() { + let diagnostics = refused(check_edit(|p| { + p.constitution.max_turns = 99; + p.constitution.permitted_edits.push(PatchClass::Policy); + })); + assert_eq!( + located(&diagnostics), + [ + ("constitution.json", Some("/max_turns"), Rule::Immutable), + ( + "constitution.json", + Some("/permitted_edits/6"), + Rule::Immutable + ), + ] + ); + assert_eq!( + diagnostics[0].message, + "the constitution is host-owned and no edit may change it" + ); +} + +#[test] +fn a_protected_manifest_key_is_immutable_even_under_a_permitted_class() { + let diagnostics = refused(check_edit(|p| { + p.manifest.seats[0].config = json!({"telemetry": {"enabled": false}}); + })); + assert_eq!( + located(&diagnostics), + [( + "hive.json", + Some("/seats/0/config/telemetry"), + Rule::Immutable + )] + ); + assert!(diagnostics[0].message.starts_with("telemetry settings")); +} + +#[test] +fn a_protected_frontmatter_key_is_reported_on_its_markdown_file() { + let diagnostics = refused(check_edit(|p| { + p.documents.insert( + "README.md".into(), + "---\n{\"evaluation\":1}\n---\nNotes\n".into(), + ); + })); + assert_eq!( + located(&diagnostics), + [ + ("README.md", None, Rule::Immutable), + ("README.md", None, Rule::Unowned), + ] + ); + assert!( + diagnostics[0] + .message + .ends_with("(frontmatter /evaluation)") + ); +} + +#[test] +fn changing_an_existing_frontmatter_is_immutable() { + let diagnostics = refused(check_edit(|p| { + p.documents.insert( + "seats/alice.md".into(), + "---\n{\"id\":\"alice\",\"label\":\"alice\"}\n---\nDraft the change.\n".into(), + ); + })); + assert_eq!( + located(&diagnostics), + [("seats/alice.md", None, Rule::Immutable)] + ); + assert!(diagnostics[0].message.starts_with("frontmatter restates")); +} + +#[test] +fn a_body_may_not_open_a_second_frontmatter() { + let diagnostics = refused(check_edit(|p| { + p.documents.insert( + "seats/alice.md".into(), + "---\n{\"id\":\"alice\"}\n---\n---\nsmuggled\n".into(), + ); + })); + assert_eq!( + located(&diagnostics), + [("seats/alice.md", None, Rule::Immutable)] + ); + assert!(diagnostics[0].message.contains("second frontmatter")); +} + +#[test] +fn an_unpermitted_class_is_refused_for_documents_and_fields() { + let diagnostics = refused(check_edit(|p| { + p.manifest.policies.digest.budget_chars = 10; + })); + assert_eq!( + located(&diagnostics), + [( + "hive.json", + Some("/policies/digest/budget_chars"), + Rule::ForbiddenClass + )] + ); + let mut base = package(); + base.constitution.permitted_edits = vec![PatchClass::Prompt]; + let mut edit = base.clone(); + edit.manifest.seats.push(seat("bob")); + edit.documents + .insert("seats/bob.md".into(), "Review.\n".into()); + let diagnostics = refused(crate::check(&files(&base), &files(&edit))); + assert_eq!( + located(&diagnostics), + [ + ("hive.json", Some("/seats/1"), Rule::ForbiddenClass), + ("seats/bob.md", None, Rule::ForbiddenClass), + ] + ); + assert_eq!( + diagnostics[1].message, + "the constitution does not permit roster edits" + ); +} + +#[test] +fn a_canary_is_located_in_json_values_keys_and_markdown() { + let diagnostics = refused(check_edit(|p| { + p.manifest.goals[0].description = "see CANARY-1".into(); + p.manifest.seats[0].config = json!({"CANARY-1": true}); + p.documents + .insert("roles/writer.md".into(), "CANARY-1\n".into()); + })); + assert_eq!( + located(&diagnostics), + [ + ( + "hive.json", + Some("/goals/0/description"), + Rule::Contamination + ), + ("roles/writer.md", None, Rule::Contamination), + ] + ); + let keyed = refused(check_edit(|p| { + p.manifest.seats[0].config = json!({"CANARY-1": true}); + })); + assert_eq!( + located(&keyed), + [( + "hive.json", + Some("/seats/0/config/CANARY-1"), + Rule::Contamination + )] + ); +} + +#[test] +fn a_pinned_memory_and_its_bindings_are_read_only() { + let diagnostics = refused(check_edit(|p| { + p.memory.identities[1].lifecycle = crate::MemoryLifecycle::Run; + })); + assert_eq!( + located(&diagnostics), + [("memory.json", Some("/identities/1"), Rule::ReadOnly)] + ); + assert_eq!( + diagnostics[0].message, + "memory ledger is pinned read-only by the constitution" + ); + let bound = refused(check_edit(|p| { + p.memory.bindings[0].identity = "ledger".into(); + })); + assert_eq!( + located(&bound), + [("memory.json", Some("/bindings/0"), Rule::ReadOnly)] + ); + let removed = refused(check_edit(|p| { + p.memory.identities.pop(); + })); + assert_eq!( + located(&removed), + [ + ( + "constitution.json", + Some("/read_only_memories/0"), + Rule::Reference + ), + ("memory.json", Some("/identities/1"), Rule::ReadOnly), + ] + ); +} + +#[test] +fn a_context_edit_must_be_expressible_as_item_deltas() { + let notes = |body: &str| { + check_edit(move |p| { + p.documents.insert("context/notes.md".into(), body.into()); + }) + }; + let item = |result| { + located(&refused(result)) + .first() + .map(|d| (d.0.to_owned(), d.2)) + }; + let expected = Some(("context/notes.md".to_owned(), Rule::Context)); + // Text before the first item changed. + assert_eq!( + item(notes( + "# Renamed\n\nFirst.\n\nSecond.\n" + )), + expected + ); + // Items swapped. + assert_eq!( + item(notes( + "# Notes\n\nSecond.\n\nFirst.\n" + )), + expected + ); + // A new item before the last one. + assert_eq!( + item(notes( + "# Notes\n\nFirst.\n\nNew.\n\nSecond.\n" + )), + expected + ); + // A last item without its newline. + assert_eq!( + item(notes( + "# Notes\n\nFirst.\n\nSecond." + )), + expected + ); + // A duplicate marker. + assert_eq!( + item(notes( + "# Notes\n\nFirst.\n\nSecond.\n" + )), + expected + ); + // An appended item is fine. + let review = accepted(notes( + "# Notes\n\nFirst.\n\nSecond.\n\nThird.\n", + )); + assert_eq!(classes(&review), [PatchClass::Context]); +} + +#[test] +fn a_new_context_document_must_carry_well_formed_markers() { + let diagnostics = refused(check_edit(|p| { + p.documents.insert( + "context/extra.md".into(), + "\nx\n".into(), + ); + })); + assert_eq!( + located(&diagnostics), + [("context/extra.md", None, Rule::Context)] + ); +} + +#[test] +fn identity_fields_no_class_owns_are_unowned() { + let diagnostics = refused(check_edit(|p| { + p.manifest.id = "renamed".into(); + p.manifest.version = 2; + })); + assert_eq!( + located(&diagnostics), + [ + ("hive.json", Some("/id"), Rule::Unowned), + ("hive.json", Some("/version"), Rule::Schema), + ("hive.json", Some("/version"), Rule::Unowned), + ] + ); + assert!(diagnostics[0].message.starts_with("no edit class owns id")); +} diff --git a/crates/tinyhivemind-lang/src/check/test/locations.rs b/crates/tinyhivemind-lang/src/check/test/locations.rs new file mode 100644 index 00000000..6fec7abc --- /dev/null +++ b/crates/tinyhivemind-lang/src/check/test/locations.rs @@ -0,0 +1,79 @@ +//! Locating a change: the first differing leaf, an operation's file, and the +//! review wire. +use super::super::rules::{first_difference, location}; +use super::support::*; +use crate::patch::{Operation, Patch, PatchClass}; +use serde_json::json; + +#[test] +fn the_first_difference_descends_to_the_changed_leaf() { + let before = json!({"a": {"b": [1, 2, {"c": 3}]}, "z": 1}); + let after = json!({"a": {"b": [1, 2, {"c": 4}]}, "z": 1}); + assert_eq!(first_difference(Some(&before), Some(&after)), "/a/b/2/c"); + assert_eq!( + first_difference(Some(&json!([1])), Some(&json!([1, 2]))), + "/1" + ); + assert_eq!( + first_difference(Some(&json!({"a/b~": 1})), Some(&json!({}))), + "/a~1b~0" + ); + assert_eq!(first_difference(Some(&json!(1)), Some(&json!("1"))), ""); + assert_eq!(first_difference(None, Some(&json!({}))), ""); + assert_eq!(first_difference(Some(&json!({})), Some(&json!({}))), ""); + assert_eq!(first_difference(Some(&json!([])), Some(&json!([]))), ""); +} + +#[test] +fn an_operation_is_located_in_the_file_it_changes() { + let json = |pointer: &str| Operation::Json { + pointer: pointer.into(), + before: Some(json!({"x": 1})), + after: Some(json!({"x": 2})), + }; + assert_eq!( + location(&json("/manifest/policies")), + ("hive.json".into(), Some("/policies/x".into())) + ); + assert_eq!( + location(&json("/memory/bindings")), + ("memory.json".into(), Some("/bindings/x".into())) + ); + let learning = Operation::Learning { + identity: "notes".into(), + entry: "e".into(), + watermark: 0, + before: None, + after: Some("x".into()), + }; + assert_eq!(location(&learning), ("memory.json".into(), None)); + let prompt = Operation::Prompt { + path: "seats/alice.md".into(), + before: String::new(), + after: String::new(), + }; + assert_eq!(location(&prompt), ("seats/alice.md".into(), None)); +} + +#[test] +fn pins_the_review_wire() { + let review = crate::Review { + changes: vec![Patch { + class: PatchClass::Prompt, + operations: vec![], + }], + candidate: package(), + }; + let wire = serde_json::to_value(&review).expect("a review serializes"); + assert_eq!( + wire["changes"], + json!([{"class": "prompt", "operations": []}]) + ); + assert_eq!( + wire.as_object() + .map(|o| o.keys().cloned().collect::>()), + Some(vec!["candidate".to_owned(), "changes".to_owned()]) + ); + let back: crate::Review = serde_json::from_value(wire).expect("a review decodes"); + assert_eq!(back, review); +} diff --git a/crates/tinyhivemind-lang/src/check/test/mod.rs b/crates/tinyhivemind-lang/src/check/test/mod.rs new file mode 100644 index 00000000..4cf83924 --- /dev/null +++ b/crates/tinyhivemind-lang/src/check/test/mod.rs @@ -0,0 +1,9 @@ +//! Direct-edit checking: each edit rule, raw-file fallbacks, and the order +//! of derived per-class changes. +#![allow(clippy::expect_used, clippy::panic)] // Tests fail loudly on unexpected results. + +mod files; +mod guards; +mod locations; +mod sequence; +mod support; diff --git a/crates/tinyhivemind-lang/src/check/test/sequence.rs b/crates/tinyhivemind-lang/src/check/test/sequence.rs new file mode 100644 index 00000000..3b564cb6 --- /dev/null +++ b/crates/tinyhivemind-lang/src/check/test/sequence.rs @@ -0,0 +1,107 @@ +//! Derived changes: one patch per class, in an order valid at every step. +use super::support::*; +use crate::{Rule, patch, patch::PatchClass}; + +#[test] +fn an_unchanged_package_needs_no_change() { + let review = accepted(check_edit(|_| {})); + assert_eq!((review.changes).len(), 0); + assert_eq!(review.candidate, package()); +} + +#[test] +fn a_single_class_change_equals_what_diff_derives() { + let review = accepted(check_edit(|p| { + p.manifest.topology.clear(); + })); + assert_eq!(classes(&review), [PatchClass::Topology]); + let diffed = patch::diff(&package(), &review.candidate, PatchClass::Topology) + .expect("diff derives the same change"); + assert_eq!(review.changes, [diffed]); +} + +#[test] +fn a_dependent_change_is_ordered_after_what_it_depends_on() { + // The binding names a seat the roster change adds, so memory, which comes + // first in class order, can only apply after the roster. + let review = accepted(check_edit(|p| { + p.manifest.seats.push(seat("bob")); + p.manifest.desks[0].members.push("bob".into()); + p.documents + .insert("seats/bob.md".into(), "Review.\n".into()); + p.memory + .identities + .push(identity("bob-notes", "team:hive/agent:bob", false)); + p.memory.bindings.push(binding("bob", "bob-notes")); + })); + assert_eq!(classes(&review), [PatchClass::Roster, PatchClass::Memory]); + let mut current = package(); + for change in &review.changes { + current = patch::apply(¤t, change) + .expect("each derived change applies") + .candidate; + } + assert_eq!(current, review.candidate); +} + +#[test] +fn a_change_with_no_valid_order_is_a_sequence_finding() { + // The new prompt's frontmatter restates a binding the memory change adds, + // while that binding names the seat the roster change adds: neither step + // is valid alone. + let diagnostics = refused(check_edit(|p| { + p.manifest.seats.push(seat("bob")); + p.documents.insert( + "seats/bob.md".into(), + "---\n{\"memory\":\"bob-notes\"}\n---\nReview.\n".into(), + ); + p.memory + .identities + .push(identity("bob-notes", "team:hive/agent:bob", false)); + p.memory.bindings.push(binding("bob", "bob-notes")); + })); + assert_eq!( + located(&diagnostics), + [("memory.json", None, Rule::Sequence)] + ); + let message = &diagnostics[0].message; + assert!( + message.starts_with("no order of the memory, roster changes"), + "{message}" + ); +} + +#[test] +fn every_class_can_change_in_one_edit() { + let mut base = package(); + base.constitution.permitted_edits.push(PatchClass::Policy); + let mut edit = base.clone(); + edit.documents + .insert("roles/writer.md".into(), "Write tersely.\n".into()); + edit.documents.insert( + "context/notes.md".into(), + "# Notes\n\nFirst!\n\nSecond.\n".into(), + ); + edit.memory.bindings[0].budget_chars = 1024; + edit.manifest.seats[0].label = "Alice".into(); + edit.manifest.referrals.push(crate::Link { + from: "side".into(), + to: "main".into(), + }); + edit.manifest.policies.digest.budget_chars = 2000; + edit.manifest.goals[0].description = "Ship it".into(); + let review = accepted(crate::check(&files(&base), &files(&edit))); + assert_eq!( + classes(&review), + [ + PatchClass::Prompt, + PatchClass::Context, + PatchClass::Memory, + PatchClass::Roster, + PatchClass::Topology, + PatchClass::Policy, + PatchClass::Goal, + ] + ); + assert_eq!(review.candidate, edit); +} diff --git a/crates/tinyhivemind-lang/src/check/test/support.rs b/crates/tinyhivemind-lang/src/check/test/support.rs new file mode 100644 index 00000000..e62edcb8 --- /dev/null +++ b/crates/tinyhivemind-lang/src/check/test/support.rs @@ -0,0 +1,172 @@ +//! A small package touching every edit class, and helpers to edit its files. +use crate::{ + Diagnostic, Goal, Link, MemoryIdentity, MemoryKind, MemoryLifecycle, MemoryReach, Mission, + Package, RecallMoment, Review, Role, Rule, Seat, SeatMemory, patch::PatchClass, +}; +use std::collections::BTreeMap; +use tinyhivemind_core::desk::Desk; + +pub(super) type Checked = Result>; + +/// A valid package: one seat, two desks, a goal, itemized context, an agent +/// memory and a pinned read-only ledger. Every class but `policy` is +/// permitted. +pub(super) fn package() -> Package { + let mut p = Package::default(); + p.manifest.roles.push(Role { + id: "writer".into(), + charter: "roles/writer.md".into(), + }); + p.manifest.seats.push(seat("alice")); + for id in ["main", "side"] { + p.manifest.desks.push(desk(id, &["alice"])); + } + p.manifest.topology.push(Link { + from: "main".into(), + to: "side".into(), + }); + p.manifest.goals.push(Goal { + id: "ship".into(), + description: "Ship the change".into(), + acceptance: vec!["tests pass".into()], + subgoals: vec![], + }); + p.manifest.missions.push(Mission { + id: "deliver".into(), + role: "writer".into(), + goals: vec!["ship".into()], + }); + p.manifest.context.push("context/notes.md".into()); + p.documents + .insert("roles/writer.md".into(), "Write clearly.\n".into()); + p.documents.insert( + "seats/alice.md".into(), + "---\n{\"id\":\"alice\"}\n---\nDraft the change.\n".into(), + ); + p.documents.insert( + "context/notes.md".into(), + "# Notes\n\nFirst.\n\nSecond.\n".into(), + ); + p.memory + .identities + .push(identity("notes", "team:hive/agent:alice", false)); + let mut ledger = identity("ledger", "team:hive/project:ledger", true); + ledger.kind = MemoryKind::Pool; + p.memory.identities.push(ledger); + p.memory.bindings.push(binding("alice", "notes")); + p.constitution.permitted_edits = vec![ + PatchClass::Prompt, + PatchClass::Context, + PatchClass::Memory, + PatchClass::Roster, + PatchClass::Topology, + PatchClass::Goal, + ]; + p.constitution.read_only_memories = vec!["ledger".into()]; + p.constitution.contamination_canaries = vec!["CANARY-1".into()]; + p.constitution.evaluation = serde_json::json!({"kind": "exact"}); + p +} + +pub(super) fn seat(id: &str) -> Seat { + Seat { + id: id.into(), + label: id.into(), + role: "writer".into(), + prompt: format!("seats/{id}.md"), + template: "agent".into(), + config: serde_json::json!({}), + ..Seat::default() + } +} + +pub(super) fn desk(id: &str, members: &[&str]) -> Desk { + serde_json::from_value(serde_json::json!({ + "id": id, + "name": id, + "description": null, + "members": members, + "responder_mode": "auto" + })) + .expect("a desk literal decodes") +} + +pub(super) fn identity(id: &str, namespace: &str, read_only: bool) -> MemoryIdentity { + MemoryIdentity { + id: id.into(), + namespace: namespace.into(), + kind: MemoryKind::Agent, + lifecycle: MemoryLifecycle::Hive, + read_only, + fork_parent: None, + } +} + +pub(super) fn binding(seat: &str, identity: &str) -> SeatMemory { + SeatMemory { + seat: seat.into(), + identity: identity.into(), + reads: vec![MemoryReach { + at: format!("team:hive/agent:{seat}"), + inherit: false, + descendants: false, + }], + recall_at: vec![RecallMoment::SessionStart], + budget_chars: 512, + remember: vec![], + } +} + +/// A package as the document map an editor would see on disk. +pub(super) fn files(package: &Package) -> BTreeMap { + let mut files = package.documents.clone(); + for (path, value) in [ + ("hive.json", serde_json::to_string_pretty(&package.manifest)), + ( + "constitution.json", + serde_json::to_string_pretty(&package.constitution), + ), + ("memory.json", serde_json::to_string_pretty(&package.memory)), + ] { + files.insert(path.into(), value.expect("wire types serialize")); + } + files +} + +/// Check `edit` of the fixture, by files. +pub(super) fn check_edit(edit: impl FnOnce(&mut Package)) -> Checked { + let base = package(); + let mut edited = base.clone(); + edit(&mut edited); + crate::check(&files(&base), &files(&edited)) +} + +/// The diagnostics of a refused check. +pub(super) fn refused(result: Checked) -> Vec { + result.expect_err("expected the edit to be refused") +} + +/// The review of an accepted check, failing with its diagnostics otherwise. +pub(super) fn accepted(result: Checked) -> Review { + match result { + Ok(review) => review, + Err(diagnostics) => panic!("edit refused: {diagnostics:#?}"), + } +} + +pub(super) fn located(diagnostics: &[Diagnostic]) -> Vec<(&str, Option<&str>, Rule)> { + diagnostics + .iter() + .map(|d| (d.file.as_str(), d.pointer.as_deref(), d.rule)) + .collect() +} + +pub(super) fn classes(review: &Review) -> Vec { + review.changes.iter().map(|change| change.class).collect() +} + +#[test] +fn the_fixture_is_valid() { + assert_eq!(crate::diagnose(&package()), []); + assert_eq!(crate::parse(&files(&package())), Ok(package())); +} diff --git a/crates/tinyhivemind-lang/src/check/types.rs b/crates/tinyhivemind-lang/src/check/types.rs new file mode 100644 index 00000000..d9341b0a --- /dev/null +++ b/crates/tinyhivemind-lang/src/check/types.rs @@ -0,0 +1,21 @@ +//! The result of a successful check: the recorded form of a direct edit. + +use crate::{Package, patch::Patch}; +use serde::{Deserialize, Serialize}; + +/// An accepted direct edit, classified for lineage. +/// +/// `changes` holds one [`Patch`] per edit class the edit touched. Applying +/// them in order with [`crate::patch::apply`], starting from the original +/// package, passes through a valid package at every step and ends at exactly +/// `candidate`. A host records one [`crate::lineage::Record`] per change, +/// each naming the previous one as its parent, so every step keeps its own +/// class and content identity and the last identity is the candidate's. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Review { + /// One patch per touched class, in the order they apply. + pub changes: Vec, + /// The edited package, decoded and validated. + pub candidate: Package, +} diff --git a/crates/tinyhivemind-lang/src/diagnostic/README.md b/crates/tinyhivemind-lang/src/diagnostic/README.md new file mode 100644 index 00000000..9a1117e4 --- /dev/null +++ b/crates/tinyhivemind-lang/src/diagnostic/README.md @@ -0,0 +1,18 @@ +# diagnostic + +The located finding every rule reports. A `Diagnostic` names a `file`, an +optional JSON `pointer` into it, a `rule` family and a lowercase `message`, +and displays compiler style: `hive.json:/seats/0/role: reference: unresolved +role reference ghost`. `Rule` has one snake_case variant per family; the first +twelve mirror `crate::Error`, the rest are the edit rules `check` adds. + +Inside the crate, validation and parsing push `(Error, file, pointer)` +findings into a `Findings` collector. `validate` and `parse` return its first +finding as the error they always returned; `diagnose`, `diagnose_documents` +and `check` return all of them, without repeats. + +| File | Purpose | +| --- | --- | +| `mod.rs` | The collector, canonical file names, and pointer escaping | +| `types.rs` | `Diagnostic`, `Rule`, and the `Error`-to-`Rule` mapping | +| `test.rs` | The pinned wire, every rule name, the error mapping, and the collector | diff --git a/crates/tinyhivemind-lang/src/diagnostic/mod.rs b/crates/tinyhivemind-lang/src/diagnostic/mod.rs new file mode 100644 index 00000000..00a14a4d --- /dev/null +++ b/crates/tinyhivemind-lang/src/diagnostic/mod.rs @@ -0,0 +1,87 @@ +//! Located findings: the compiler-style report every rule writes into. +//! +//! Validation, parsing and [`crate::check()`] push each finding into a +//! private collector together with the file and JSON Pointer it concerns. +//! [`crate::validate`] and [`crate::parse`] return the first finding as an +//! [`crate::Error`]; [`crate::diagnose`], [`crate::diagnose_documents`] and +//! the checker return all of them as [`Diagnostic`]s. + +mod types; +use crate::{Error, Result}; +pub use types::{Diagnostic, Rule}; + +/// The canonical manifest file. +pub(crate) const HIVE: &str = "hive.json"; +/// The canonical constitution file. +pub(crate) const CONSTITUTION: &str = "constitution.json"; +/// The canonical memory file. +pub(crate) const MEMORY: &str = "memory.json"; + +/// One package error and where it was found. +#[derive(Debug)] +pub(crate) struct Finding { + error: Error, + file: String, + pointer: Option, +} + +/// Findings in the order the rules produced them. +#[derive(Debug, Default)] +pub(crate) struct Findings(Vec); + +impl Findings { + /// Record `error` against `file` at `pointer`. + pub(crate) fn push(&mut self, error: Error, file: &str, pointer: impl Into>) { + self.0.push(Finding { + error, + file: file.to_owned(), + pointer: pointer.into(), + }); + } + + /// Record the failure of `result`, if it failed; report whether it passed. + pub(crate) fn check( + &mut self, + result: Result<()>, + file: &str, + pointer: impl Into>, + ) -> bool { + match result { + Ok(()) => true, + Err(error) => { + self.push(error, file, pointer); + false + } + } + } + + /// The first finding, as the error fail-fast validation returns. + pub(crate) fn into_first(self) -> Result<()> { + self.0.into_iter().next().map_or(Ok(()), |f| Err(f.error)) + } + + /// Every finding as a diagnostic, without repeats, in production order. + pub(crate) fn into_diagnostics(self) -> Vec { + let mut diagnostics: Vec = Vec::with_capacity(self.0.len()); + for finding in self.0 { + let diagnostic = Diagnostic { + rule: Rule::from(&finding.error), + message: finding.error.to_string(), + file: finding.file, + pointer: finding.pointer, + }; + if !diagnostics.contains(&diagnostic) { + diagnostics.push(diagnostic); + } + } + diagnostics + } +} + +/// Escape one JSON Pointer reference token (RFC 6901). +pub(crate) fn escape(token: &str) -> String { + token.replace('~', "~0").replace('/', "~1") +} + +#[cfg(test)] +mod test; diff --git a/crates/tinyhivemind-lang/src/diagnostic/test.rs b/crates/tinyhivemind-lang/src/diagnostic/test.rs new file mode 100644 index 00000000..2866346c --- /dev/null +++ b/crates/tinyhivemind-lang/src/diagnostic/test.rs @@ -0,0 +1,143 @@ +//! The diagnostic wire, rule names, error mapping and the collector. +#![allow(clippy::expect_used)] // Tests fail loudly on unexpected results. +use super::*; +use serde_json::json; + +const RULES: [(Rule, &str); 19] = [ + (Rule::Schema, "schema"), + (Rule::Identity, "identity"), + (Rule::Reference, "reference"), + (Rule::Path, "path"), + (Rule::Bound, "bound"), + (Rule::GoalCycle, "goal_cycle"), + (Rule::Namespace, "namespace"), + (Rule::MemoryAccess, "memory_access"), + (Rule::Context, "context"), + (Rule::Frontmatter, "frontmatter"), + (Rule::Json, "json"), + (Rule::MissingDocument, "missing_document"), + (Rule::ForbiddenClass, "forbidden_class"), + (Rule::Immutable, "immutable"), + (Rule::Contamination, "contamination"), + (Rule::ReadOnly, "read_only"), + (Rule::Unowned, "unowned"), + (Rule::Original, "original"), + (Rule::Sequence, "sequence"), +]; + +#[test] +fn pins_the_diagnostic_wire_with_and_without_a_pointer() { + let located = Diagnostic { + file: "hive.json".into(), + pointer: Some("/seats/0/role".into()), + rule: Rule::Reference, + message: "unresolved role reference ghost".into(), + }; + assert_eq!( + serde_json::to_value(&located).expect("serializes"), + json!({ + "file": "hive.json", + "pointer": "/seats/0/role", + "rule": "reference", + "message": "unresolved role reference ghost" + }) + ); + let whole = Diagnostic { + pointer: None, + file: "seats/a.md".into(), + ..located.clone() + }; + let wire = serde_json::to_value(&whole).expect("serializes"); + assert_eq!(wire["pointer"], serde_json::Value::Null); + assert_eq!( + serde_json::from_value::(wire).expect("decodes"), + whole + ); + let unknown = json!({"file": "a", "pointer": null, "rule": "json", "message": "m", "x": 1}); + assert!(serde_json::from_value::(unknown).is_err()); +} + +#[test] +fn displays_compiler_style() { + let mut diagnostic = Diagnostic { + file: "hive.json".into(), + pointer: Some("/seats/3/role".into()), + rule: Rule::Reference, + message: "unresolved role reference x".into(), + }; + assert_eq!( + diagnostic.to_string(), + "hive.json:/seats/3/role: reference: unresolved role reference x" + ); + diagnostic.pointer = None; + assert_eq!( + diagnostic.to_string(), + "hive.json: reference: unresolved role reference x" + ); +} + +#[test] +fn every_rule_has_one_snake_case_name_on_the_wire_and_in_display() { + for (rule, name) in RULES { + assert_eq!(rule.name(), name); + assert_eq!(rule.to_string(), name); + assert_eq!(serde_json::to_value(rule).expect("serializes"), json!(name)); + assert_eq!( + serde_json::from_value::(json!(name)).expect("decodes"), + rule + ); + } +} + +#[test] +fn every_package_error_maps_to_its_rule() { + let s = String::new; + let cases = [ + (Error::MissingDocument { path: s() }, Rule::MissingDocument), + ( + Error::Json { + path: s(), + message: s(), + }, + Rule::Json, + ), + (Error::Schema, Rule::Schema), + (Error::Identity { kind: s(), id: s() }, Rule::Identity), + (Error::Reference { kind: s(), id: s() }, Rule::Reference), + (Error::Path { path: s() }, Rule::Path), + (Error::Bound { field: s() }, Rule::Bound), + (Error::GoalCycle { id: s() }, Rule::GoalCycle), + (Error::Namespace { namespace: s() }, Rule::Namespace), + (Error::MemoryAccess { namespace: s() }, Rule::MemoryAccess), + (Error::Context { path: s() }, Rule::Context), + (Error::Frontmatter { seat: s() }, Rule::Frontmatter), + ]; + for (error, rule) in cases { + assert_eq!(Rule::from(&error), rule); + } +} + +#[test] +fn the_collector_keeps_order_drops_repeats_and_yields_the_first_error() { + let mut found = Findings::default(); + assert!(found.check(Ok(()), HIVE, None)); + assert!(!found.check(Err(Error::Schema), HIVE, "/version".to_owned())); + found.push(Error::Schema, HIVE, "/version".to_owned()); + found.push(Error::Path { path: "a".into() }, "a", None); + let diagnostics = Findings(vec![]).into_diagnostics(); + assert_eq!((diagnostics).len(), 0); + let mut second = Findings::default(); + second.push(Error::Schema, HIVE, None); + assert_eq!(second.into_first(), Err(Error::Schema)); + assert_eq!(Findings::default().into_first(), Ok(())); + let diagnostics = found.into_diagnostics(); + assert_eq!(diagnostics.len(), 2); + assert_eq!(diagnostics[0].pointer.as_deref(), Some("/version")); + assert_eq!(diagnostics[1].rule, Rule::Path); +} + +#[test] +fn escapes_pointer_tokens() { + assert_eq!(escape("a/b~c"), "a~1b~0c"); + assert_eq!(escape("plain"), "plain"); +} diff --git a/crates/tinyhivemind-lang/src/diagnostic/types.rs b/crates/tinyhivemind-lang/src/diagnostic/types.rs new file mode 100644 index 00000000..a22246f2 --- /dev/null +++ b/crates/tinyhivemind-lang/src/diagnostic/types.rs @@ -0,0 +1,143 @@ +//! The diagnostic wire: where a finding lives, which rule it breaks, and why. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// One finding against a hive package, located like a compiler error. +/// +/// Displays as `file:pointer: rule: message`, or `file: rule: message` when +/// there is no pointer. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Diagnostic { + /// Package-relative path of the file the finding concerns, such as + /// `hive.json` or `seats/solver.md`. + pub file: String, + /// JSON Pointer inside `file`, or `None` for a Markdown file or a finding + /// about the whole document. + pub pointer: Option, + /// The rule family the finding breaks. + pub rule: Rule, + /// Lowercase explanation without trailing punctuation. + pub message: String, +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.pointer { + Some(pointer) => write!( + f, + "{}:{pointer}: {}: {}", + self.file, self.rule, self.message + ), + None => write!(f, "{}: {}: {}", self.file, self.rule, self.message), + } + } +} + +/// The rule family a [`Diagnostic`] reports. +/// +/// The first twelve mirror the package [`crate::Error`] variants; the rest are +/// the rules [`crate::check()`] adds for an edit. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Rule { + /// Unsupported `$schema` or `version` ([`crate::Error::Schema`]). + Schema, + /// An empty or duplicate identity ([`crate::Error::Identity`]). + Identity, + /// A reference that does not resolve ([`crate::Error::Reference`]). + Reference, + /// An unsafe or misplaced document path ([`crate::Error::Path`]). + Path, + /// A policy outside its bound or the constitution's caps + /// ([`crate::Error::Bound`]). + Bound, + /// A cyclic goal decomposition ([`crate::Error::GoalCycle`]). + GoalCycle, + /// A malformed memory namespace ([`crate::Error::Namespace`]). + Namespace, + /// A memory identity or reach the constitution forbids + /// ([`crate::Error::MemoryAccess`]). + MemoryAccess, + /// Malformed context item markers, or a context edit that item deltas + /// cannot express ([`crate::Error::Context`]). + Context, + /// Frontmatter that is malformed or disagrees with the manifest + /// ([`crate::Error::Frontmatter`]). + Frontmatter, + /// A canonical file that is not valid JSON for its schema + /// ([`crate::Error::Json`]). + Json, + /// A required or referenced document that is absent + /// ([`crate::Error::MissingDocument`]). + MissingDocument, + /// A change of an edit class the constitution does not permit. + ForbiddenClass, + /// A change to the constitution, a protected key, or a frontmatter. + Immutable, + /// A contamination canary in the edited package. + Contamination, + /// A change to a memory identity, or its bindings, pinned read-only. + ReadOnly, + /// A change no edit class owns, or a file that is not part of a package. + Unowned, + /// A finding against the original package rather than the edit. + Original, + /// No order of the per-class changes is valid at every step. + Sequence, +} + +impl Rule { + /// The rule's wire name, such as `goal_cycle`. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Schema => "schema", + Self::Identity => "identity", + Self::Reference => "reference", + Self::Path => "path", + Self::Bound => "bound", + Self::GoalCycle => "goal_cycle", + Self::Namespace => "namespace", + Self::MemoryAccess => "memory_access", + Self::Context => "context", + Self::Frontmatter => "frontmatter", + Self::Json => "json", + Self::MissingDocument => "missing_document", + Self::ForbiddenClass => "forbidden_class", + Self::Immutable => "immutable", + Self::Contamination => "contamination", + Self::ReadOnly => "read_only", + Self::Unowned => "unowned", + Self::Original => "original", + Self::Sequence => "sequence", + } + } +} + +impl fmt::Display for Rule { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +impl From<&crate::Error> for Rule { + fn from(error: &crate::Error) -> Self { + use crate::Error; + match error { + Error::MissingDocument { .. } => Self::MissingDocument, + Error::Json { .. } => Self::Json, + Error::Schema => Self::Schema, + Error::Identity { .. } => Self::Identity, + Error::Reference { .. } => Self::Reference, + Error::Path { .. } => Self::Path, + Error::Bound { .. } => Self::Bound, + Error::GoalCycle { .. } => Self::GoalCycle, + Error::Namespace { .. } => Self::Namespace, + Error::MemoryAccess { .. } => Self::MemoryAccess, + Error::Context { .. } => Self::Context, + Error::Frontmatter { .. } => Self::Frontmatter, + } + } +} diff --git a/crates/tinyhivemind-lang/src/document/README.md b/crates/tinyhivemind-lang/src/document/README.md index d47e3dce..d08afafd 100644 --- a/crates/tinyhivemind-lang/src/document/README.md +++ b/crates/tinyhivemind-lang/src/document/README.md @@ -1,8 +1,28 @@ # document -Defines strict manifest JSON, seat metadata and caller-owned Markdown parsing. The host supplies the documents; this module never opens files. +The package wire format and the parser that reads it. A host collects a +package's files into a `BTreeMap` and calls `parse`; this +module never opens a file. + +`parse` checks that every key is a safe relative path, decodes `hive.json`, +`constitution.json` and `memory.json` (each required), keeps every other key +ending in `.md` as a document and drops the rest, then runs `validate`. Every +wire struct denies unknown fields, and a second pass rejects unknown fields +nested inside core policy values, which serde alone would accept. The one +exception is `"responder_mode": "lead"` on a desk: core omits the lead mode +when it serializes, so an explicit `lead` is accepted. + +`diagnose_documents` runs the same steps without stopping: every unsafe +path, every canonical file that is missing or fails to decode (with serde's +line and column), an unknown nested field at its JSON Pointer, and every +validation finding once the three files decode. `parse` returns its first +finding. | File | Purpose | | --- | --- | -| types.rs | Portable wire types and their documented fields. | -| mod.rs | Public document API and implementation. | +| `mod.rs` | `parse`, `diagnose_documents`, JSON decoding, and the nested unknown-field check shared with `patch` | +| `types.rs` | `Package`, `Manifest` and its sections, `Policies`, and coordinator settings | +| `test.rs` | Collected parse findings and their agreement with `parse` | + +Parsing regressions also run alongside validation in +[`../validation/test/rules.rs`](../validation/test/rules.rs). diff --git a/crates/tinyhivemind-lang/src/document/mod.rs b/crates/tinyhivemind-lang/src/document/mod.rs index 58724225..5f956c07 100644 --- a/crates/tinyhivemind-lang/src/document/mod.rs +++ b/crates/tinyhivemind-lang/src/document/mod.rs @@ -1,19 +1,63 @@ //! Typed package manifests and caller-supplied document parsing. +//! +//! [`parse`] and [`diagnose_documents`] share one collector: every unsafe +//! path, every canonical file that is missing or does not decode, and, once +//! all three decode, every validation finding. [`parse`] returns the first; +//! [`diagnose_documents`] returns them all. mod types; -use crate::{Error, Result}; +use crate::{ + Diagnostic, Error, Result, + diagnostic::{CONSTITUTION, Findings, HIVE, MEMORY, escape}, +}; use std::collections::BTreeMap; pub use types::*; + /// Parse canonical JSON and Markdown from host-owned strings. /// # Errors -/// Returns an error for missing documents, malformed JSON or invalid packages. +/// Returns an error for missing documents, malformed JSON or invalid packages: +/// the first finding [`diagnose_documents`] would report. pub fn parse(documents: &BTreeMap) -> Result { + let (package, found) = collect(documents); + found.into_first()?; + // `collect` withholds the package only after recording why, so with no + // finding the package is present; this arm keeps the function total. + package.ok_or_else(|| Error::MissingDocument { + path: HIVE.to_owned(), + }) +} + +/// Decode and validate a document map, reporting every finding at once. +/// +/// The package is `Some` whenever `hive.json`, `constitution.json` and +/// `memory.json` decode, whether or not it then validates; it is valid +/// exactly when the diagnostics are empty. A decode failure carries serde's +/// line and column in its message, and an unknown nested field is reported +/// at its JSON Pointer. +#[must_use] +pub fn diagnose_documents( + documents: &BTreeMap, +) -> (Option, Vec) { + let (package, found) = collect(documents); + (package, found.into_diagnostics()) +} + +/// Every finding for `documents`, in the order fail-fast parsing meets them. +pub(crate) fn collect(documents: &BTreeMap) -> (Option, Findings) { + let mut found = Findings::default(); for path in documents.keys() { - crate::validation::validate_path(path)?; + found.check(crate::validation::validate_path(path), path, None); } + let manifest = decode(documents, HIVE, &mut found); + let constitution = decode(documents, CONSTITUTION, &mut found); + let memory = decode(documents, MEMORY, &mut found); + let (Some(manifest), Some(constitution), Some(memory)) = (manifest, constitution, memory) + else { + return (None, found); + }; let package = Package { - manifest: decode(documents, "hive.json")?, - constitution: decode(documents, "constitution.json")?, - memory: decode(documents, "memory.json")?, + manifest, + constitution, + memory, documents: documents .iter() .filter(|(path, _)| { @@ -24,50 +68,77 @@ pub fn parse(documents: &BTreeMap) -> Result { .map(|(p, s)| (p.clone(), s.clone())) .collect(), }; - crate::validate(&package)?; - Ok(package) + crate::validation::rules(&package, &mut found); + (Some(package), found) } + fn decode( documents: &BTreeMap, path: &str, -) -> Result { - let source = documents - .get(path) - .ok_or_else(|| Error::MissingDocument { path: path.into() })?; - let input: serde_json::Value = serde_json::from_str(source).map_err(|error| Error::Json { + found: &mut Findings, +) -> Option { + let json = |message: String| Error::Json { path: path.into(), - message: error.to_string(), - })?; - let decoded: T = serde_json::from_value(input.clone()).map_err(|error| Error::Json { - path: path.into(), - message: error.to_string(), - })?; - let output = serde_json::to_value(&decoded).map_err(|error| Error::Json { - path: path.into(), - message: error.to_string(), - })?; - if has_unknown_fields(&input, &output) { - return Err(Error::Json { - path: path.into(), - message: "unknown nested schema field".into(), - }); + message, + }; + let Some(source) = documents.get(path) else { + found.push(Error::MissingDocument { path: path.into() }, path, None); + return None; + }; + let input: serde_json::Value = match serde_json::from_str(source) { + Ok(input) => input, + Err(error) => { + found.push(json(error.to_string()), path, None); + return None; + } + }; + let decoded: T = match serde_json::from_value(input.clone()) { + Ok(decoded) => decoded, + Err(error) => { + // Decoding the source text again locates the schema error by + // line and column, which a decoded value has forgotten. + let located = serde_json::from_str::(source).err().unwrap_or(error); + found.push(json(located.to_string()), path, None); + return None; + } + }; + // The wire types serialize infallibly; were one ever to fail, a null + // output simply skips the unknown-field comparison. + let output = serde_json::to_value(&decoded).unwrap_or(serde_json::Value::Null); + if let Some(pointer) = unknown_field(&input, &output) { + found.push(json("unknown nested schema field".into()), path, pointer); + return None; } - Ok(decoded) + Some(decoded) } + pub(crate) fn has_unknown_fields(input: &serde_json::Value, output: &serde_json::Value) -> bool { + unknown_field(input, output).is_some() +} + +/// The JSON Pointer of the first input key the typed output dropped. +fn unknown_field(input: &serde_json::Value, output: &serde_json::Value) -> Option { match (input, output) { (serde_json::Value::Object(a), serde_json::Value::Object(b)) => { - a.iter().any(|(key, value)| { + a.iter().find_map(|(key, value)| { if key == "responder_mode" && value == "lead" && b.contains_key("members") { - return false; + return None; + } + let at = format!("/{}", escape(key)); + match b.get(key) { + None => Some(at), + Some(current) => unknown_field(value, current).map(|tail| at + &tail), } - b.get(key) - .is_none_or(|current| has_unknown_fields(value, current)) }) } - (serde_json::Value::Array(a), serde_json::Value::Array(b)) => { - a.iter().zip(b).any(|(a, b)| has_unknown_fields(a, b)) - } - _ => false, + (serde_json::Value::Array(a), serde_json::Value::Array(b)) => a + .iter() + .zip(b) + .enumerate() + .find_map(|(i, (a, b))| unknown_field(a, b).map(|tail| format!("/{i}{tail}"))), + _ => None, } } + +#[cfg(test)] +mod test; diff --git a/crates/tinyhivemind-lang/src/document/test.rs b/crates/tinyhivemind-lang/src/document/test.rs new file mode 100644 index 00000000..32213d2f --- /dev/null +++ b/crates/tinyhivemind-lang/src/document/test.rs @@ -0,0 +1,120 @@ +//! Collecting parse findings: every canonical file, located decode errors, +//! and agreement with `parse`. +#![allow(clippy::expect_used)] // Tests fail loudly on unexpected results. +use super::*; +use crate::{Constitution, MemorySpec, Rule}; +use serde_json::json; + +fn documents() -> BTreeMap { + BTreeMap::from([ + ( + "hive.json".to_owned(), + serde_json::to_string_pretty(&Manifest::default()).expect("serializes"), + ), + ( + "constitution.json".to_owned(), + serde_json::to_string(&Constitution::default()).expect("serializes"), + ), + ( + "memory.json".to_owned(), + serde_json::to_string(&MemorySpec::default()).expect("serializes"), + ), + ]) +} + +fn rules(diagnostics: &[Diagnostic]) -> Vec<(&str, Option<&str>, Rule)> { + diagnostics + .iter() + .map(|d| (d.file.as_str(), d.pointer.as_deref(), d.rule)) + .collect() +} + +#[test] +fn a_valid_map_decodes_with_no_findings() { + let (package, diagnostics) = diagnose_documents(&documents()); + assert_eq!(package, Some(Package::default())); + assert_eq!((diagnostics).len(), 0); +} + +#[test] +fn reports_every_missing_canonical_file_and_unsafe_path() { + let docs = BTreeMap::from([("../x.md".to_owned(), String::new())]); + let (package, diagnostics) = diagnose_documents(&docs); + assert!(package.is_none()); + assert_eq!( + rules(&diagnostics), + [ + ("../x.md", None, Rule::Path), + ("hive.json", None, Rule::MissingDocument), + ("constitution.json", None, Rule::MissingDocument), + ("memory.json", None, Rule::MissingDocument), + ] + ); + assert_eq!( + parse(&docs), + Err(Error::Path { + path: "../x.md".into() + }) + ); +} + +#[test] +fn a_schema_error_is_located_by_line_and_column() { + let mut docs = documents(); + let mut hive: serde_json::Value = serde_json::from_str(&docs["hive.json"]).expect("decodes"); + hive["surprise"] = json!(true); + docs.insert( + "hive.json".into(), + serde_json::to_string_pretty(&hive).expect("serializes"), + ); + let (_, diagnostics) = diagnose_documents(&docs); + assert_eq!(rules(&diagnostics), [("hive.json", None, Rule::Json)]); + assert!(diagnostics[0].message.contains("unknown field `surprise`")); + assert!(diagnostics[0].message.contains(" at line ")); +} + +#[test] +fn an_unknown_nested_field_is_reported_at_its_pointer() { + let mut docs = documents(); + let mut hive: serde_json::Value = serde_json::from_str(&docs["hive.json"]).expect("decodes"); + hive["policies"]["approval"]["rules"] = json!([]); + hive["desks"] = json!([ + {"id": "a", "name": "A", "description": null, "members": [], "responder_mode": "lead"}, + {"id": "b", "name": "B", "description": null, "members": [], "responder_mode": "auto", "x": 1} + ]); + docs.insert("hive.json".into(), hive.to_string()); + let (package, diagnostics) = diagnose_documents(&docs); + assert!(package.is_none()); + assert_eq!( + rules(&diagnostics), + [("hive.json", Some("/desks/1/x"), Rule::Json)] + ); + assert_eq!( + diagnostics[0].message, + "invalid json in hive.json: unknown nested schema field" + ); +} + +#[test] +fn a_decoded_but_invalid_package_is_returned_with_its_findings() { + let mut docs = documents(); + let manifest = Manifest { + id: String::new(), + version: 3, + ..Manifest::default() + }; + docs.insert( + "hive.json".into(), + serde_json::to_string(&manifest).expect("serializes"), + ); + let (package, diagnostics) = diagnose_documents(&docs); + assert!(package.is_some()); + assert_eq!( + rules(&diagnostics), + [ + ("hive.json", Some("/version"), Rule::Schema), + ("hive.json", Some("/id"), Rule::Identity), + ] + ); + assert_eq!(parse(&docs), Err(Error::Schema)); +} diff --git a/crates/tinyhivemind-lang/src/document/types.rs b/crates/tinyhivemind-lang/src/document/types.rs index 03fe3cc5..cd6c9b8b 100644 --- a/crates/tinyhivemind-lang/src/document/types.rs +++ b/crates/tinyhivemind-lang/src/document/types.rs @@ -1,4 +1,9 @@ -//! Typed package manifests and caller-supplied document parsing. +//! Wire types for the package manifest (`hive.json`) and the whole package. +//! +//! Every struct denies unknown fields, so a misspelled key is a decode error +//! rather than a silently ignored setting. Identities are opaque, +//! case-sensitive strings: [`crate::validate`] rejects an empty (or +//! whitespace-only) identity and a duplicate within the same section. use crate::{Constitution, MemorySpec}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -11,51 +16,75 @@ use tinyhivemind_core::{ hive::{DirectoryPolicy, DivisionPolicy, EpisodePolicy, ExchangePolicy}, runtime::{ApprovalPolicy, BrevityPolicy, DigestPolicy, ReferralPolicy}, }; -/// Package in the portable hive wire format. +/// A complete hive package: the three canonical JSON files plus its Markdown. +/// +/// [`crate::parse`] builds one from a document map keyed by `hive.json`, +/// `constitution.json`, `memory.json` and relative Markdown paths. The struct +/// also serializes as a single JSON object with these four fields, which is +/// the form a host can store or hand across a process boundary in one file. +/// [`Package::default`] is an empty but valid package. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Default)] #[serde(deny_unknown_fields)] pub struct Package { - /// Typed canonical hive definition. + /// The decoded `hive.json`: what the hive is made of. pub manifest: Manifest, - /// Immutable host-pinned constraints and evaluator settings. + /// The decoded `constitution.json`: caps and judging settings an editor + /// proposal may never change. pub constitution: Constitution, - /// Memory identities and binding settings without contents. + /// The decoded `memory.json`: memory identities and seat bindings, never + /// remembered contents. pub memory: MemorySpec, - /// Markdown side documents keyed by safe relative path. + /// Markdown documents keyed by safe relative path. + /// + /// [`crate::parse`] keeps only keys ending in `.md` (any case); every other + /// non-canonical file is dropped. Documents no manifest entry references, + /// such as a `README.md`, are accepted and carried along, and so take part + /// in [`crate::lineage::content_id`]. pub documents: BTreeMap, } -/// Manifest in the portable hive wire format. +/// The typed hive definition decoded from `hive.json`. +/// +/// Every field is required on the wire. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Manifest { - /// Canonical supported schema identifier. + /// The `$schema` key; must be exactly + /// `https://tinyhumans.ai/schemas/hive/v1` or validation fails with + /// [`crate::Error::Schema`]. #[serde(rename = "$schema")] pub schema: String, - /// Wire format version, currently one. + /// Wire format version; only `1` is accepted. pub version: u32, - /// Stable case-sensitive identity within this section. + /// The hive's identity; must be non-blank. Lowering carries it as + /// [`crate::LoweredHive::id`]. pub id: String, - /// Human-readable hive name. + /// Human-readable hive name, carried unchanged into lowering. pub name: String, - /// Independent organizational roles and their charter documents. + /// The roles seats and missions refer to, each with its own charter. pub roles: Vec, - /// Agents declared for host factory creation. + /// The agents the host creates, one per seat. pub seats: Vec, - /// Ordered conversations using existing core desk values. + /// Core desk values; every member must name a declared seat, at most once. pub desks: Vec, - /// Task decomposition and host-evaluated acceptance criteria. + /// Goals, which may decompose into subgoals; the decomposition must be + /// acyclic. pub goals: Vec, - /// Work obligations binding roles to goal identities. + /// Missions assigning goals to roles. pub missions: Vec, - /// Directed desk hierarchy; cycles are permitted. + /// Directed parent-to-child desk hierarchy; both ends must name declared + /// desks, and cycles are permitted. pub topology: Vec, - /// Directed channels over which desks may refer work. + /// Directed channels along which one desk may refer work to another; both + /// ends must name declared desks. pub referrals: Vec, - /// Relative Markdown paths under `context/` for shared itemized documents. + /// Paths of shared context documents. Each must start with `context/`, + /// end in `.md`, be present in [`Package::documents`], and carry + /// well-formed, unique `` markers. pub context: Vec, - /// Existing core policies, frozen in this activation snapshot. + /// Core policy values the host installs on activation, bounded by the + /// constitution. pub policies: Policies, - /// Host-neutral scheduling settings. + /// Scheduling settings for a host coordinator. pub coordinator: CoordinatorSettings, } impl Default for Manifest { @@ -78,107 +107,145 @@ impl Default for Manifest { } } } -/// Role in the portable hive wire format. +/// One role: a responsibility seats fill and missions are assigned to. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Default)] #[serde(deny_unknown_fields)] pub struct Role { - /// Stable case-sensitive identity within this section. + /// The role's identity, unique among roles. pub id: String, - /// Relative Markdown path under `roles/` for this independent charter. + /// Path of the role's charter; must start with `roles/`, end in `.md`, and + /// be present in [`Package::documents`]. + /// + /// The charter may open with JSON frontmatter between `---\n` and + /// `\n---\n`; if it does, the object may hold only `id` and `charter`, with + /// the same values as here. The charter body, without frontmatter, becomes + /// the routing candidate description of every seat filling this role. pub charter: String, } -/// Seat in the portable hive wire format. +/// One seat: an agent the host creates and places on desks. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Default)] #[serde(deny_unknown_fields)] pub struct Seat { - /// Stable case-sensitive identity within this section. + /// The seat's identity, unique among seats; desk members, memory bindings + /// and routing candidates refer to it. pub id: String, - /// Human-readable seat label. + /// Display label, also used as the routing candidate label. pub label: String, - /// Identity of the role this seat fills. + /// Identity of the [`Role`] this seat fills; must name a declared role. pub role: String, - /// Relative Markdown path under `seats/` for this seat prompt. + /// Path of the seat's prompt; must start with `seats/`, end in `.md`, and + /// be present in [`Package::documents`]. + /// + /// The prompt may open with JSON frontmatter between `---\n` and + /// `\n---\n`. Every key it holds must name a field of this seat, or one of + /// the seat's memory binding settings (`reads`, `recall_at`, + /// `budget_chars`, `remember`, and `memory` for the bound identity), with + /// exactly the manifest's value; otherwise validation fails with + /// [`crate::Error::Frontmatter`]. Lowering hands the host the body alone. pub prompt: String, - /// Host-defined factory template name; never credentials. + /// Name of the host factory template that creates the agent; must be + /// non-blank. Never a credential. pub template: String, - /// Host-defined nonsecret factory configuration. + /// Opaque, nonsecret factory configuration passed through to the host. pub config: Value, - /// Optional host model selection. + /// Model the host should run the seat on; `None` leaves it to the host. pub model: Option, - /// Explicit tool access policy for the host to enforce. + /// Tool access the host must enforce; the allow list also becomes the + /// routing candidate's capabilities. pub tools: ToolAccess, - /// Host-defined MCP server configuration names. + /// Names of host-defined MCP server configurations to attach. pub mcp: Vec, - /// Skill names installed by the host. + /// Names of skills the host installs for the seat. pub skills: Vec, } -/// `ToolAccess` in the portable hive wire format. +/// A seat's tool access, enforced by the host; this crate only carries it. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Default)] #[serde(deny_unknown_fields)] pub struct ToolAccess { - /// Tool names the host permits. + /// Tool names the seat may call. pub allow: Vec, - /// Tool names the host forbids. + /// Tool names the seat must not call. pub deny: Vec, } -/// Goal in the portable hive wire format. +/// One goal, optionally decomposed into subgoals. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Default)] #[serde(deny_unknown_fields)] pub struct Goal { - /// Stable case-sensitive identity within this section. + /// The goal's identity, unique among goals. pub id: String, - /// Human-readable purpose. + /// What the goal is for, in prose. pub description: String, - /// Criteria checked by the host evaluator. + /// Acceptance criteria, opaque to this crate and judged by the host. + /// Unlike `acceptance` elsewhere in a package, these are editable by a + /// [`crate::patch::PatchClass::Goal`] proposal. pub acceptance: Vec, - /// Child goal identities; decomposition must be acyclic. + /// Identities of child goals; each must name a declared goal, and the + /// whole decomposition must be acyclic ([`crate::Error::GoalCycle`]). pub subgoals: Vec, } -/// Mission in the portable hive wire format. +/// One mission: an obligation assigning goals to a role. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Default)] #[serde(deny_unknown_fields)] pub struct Mission { - /// Stable case-sensitive identity within this section. + /// The mission's identity, unique among missions. pub id: String, - /// Identity of the role this seat fills. + /// Identity of the [`Role`] that carries the mission; must name a declared + /// role (a role, not a seat). pub role: String, - /// Task decomposition and host-evaluated acceptance criteria. + /// Identities of the goals the role is responsible for; each must name a + /// declared [`Goal`]. pub goals: Vec, } -/// Link in the portable hive wire format. +/// A directed edge between two desks, used by topology and referrals. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Default)] #[serde(deny_unknown_fields)] pub struct Link { - /// Source desk identity. + /// Identity of the desk the edge leaves; must name a declared desk. pub from: String, - /// Target desk identity. + /// Identity of the desk the edge enters; must name a declared desk. pub to: String, } -/// Policies in the portable hive wire format. +/// The core policy values a hive runs under, each in its core wire form. +/// +/// Validation bounds them against the [`Constitution`]; a failing bound is +/// reported as [`crate::Error::Bound`] naming the field. The default is a +/// valid sequential configuration: `round_width` 1 everywhere, routing +/// thresholds at zero, and approval disabled with a deny default. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Policies { - /// Declared episode. + /// Deliberation episode policy. `turn_budget` must be nonzero and at most + /// `max_turns`; `round_width` nonzero and at most `revealed_width`, which + /// is at most `max_width`; `dominance_cap` at most 100; `repetition_cap`, + /// the quorum `threshold` and `window`, and the weights' `half_life` + /// nonzero. pub episode: EpisodePolicy, - /// Declared routing. + /// Semantic routing policy. Its four probabilities are integer parts per + /// million, at most `1000000`; `round_width` is nonzero and at most + /// `max_width`; `choice_option_limit` is nonzero. pub routing: RoutingPolicy, - /// Declared approval. + /// Approval policy for typed actions; carried without extra bounds. pub approval: ApprovalPolicy, - /// Declared aside. + /// Private aside policy; when enabled, `max_members` and `max_messages` + /// must be nonzero. pub aside: AsidePolicy, - /// Declared referral. + /// Cross-desk referral policy; when enabled, `max_hops` must be nonzero. pub referral: ReferralPolicy, - /// Declared digest. + /// Transcript digest policy; `input_limit` and `budget_chars` must be + /// nonzero. pub digest: DigestPolicy, - /// Declared brevity. + /// Message brevity policy; `message_chars` and `window` must be nonzero. pub brevity: BrevityPolicy, - /// Declared directory. + /// Expertise directory policy; `half_life` must be nonzero. pub directory: DirectoryPolicy, - /// Declared exchange. + /// Peer exchange policy; when enabled, `contact_cap` and `round_cap` must + /// be nonzero. pub exchange: ExchangePolicy, - /// Declared division. + /// Task division policy; `round_width` must be nonzero and at most + /// `max_width`. pub division: DivisionPolicy, - /// Declared conduct. + /// Conducted episode turn walls; `child_turn_wall` must be nonzero and at + /// most `turn_wall`, which is at most `max_turns`. pub conduct: ConductPolicy, } impl Default for Policies { @@ -216,17 +283,24 @@ impl Default for Policies { } } } -/// `CoordinatorSettings` in the portable hive wire format. +/// Host-neutral settings for a coordinator that schedules the hive's turns. +/// +/// This crate only validates and carries them; a host maps them onto its own +/// coordinator (the `OpenHuman` adapter's `coordinator_options` copies them +/// field for field). #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct CoordinatorSettings { - /// Declared round width. + /// Most turns the coordinator runs concurrently; nonzero and at most the + /// constitution's `max_width`. pub round_width: usize, - /// Declared conduct policy. + /// Turn walls for conducted episodes, bounded like + /// [`Policies::conduct`]. pub conduct_policy: ConductPolicy, - /// Declared broadcast budget. + /// Broadcasts allowed per assignment; `None` keeps the host driver's + /// default, and `Some(0)` is rejected. pub broadcast_budget: Option, - /// Declared retention. + /// How many settled records the coordinator keeps. pub retention: RetentionSettings, } impl Default for CoordinatorSettings { @@ -239,16 +313,16 @@ impl Default for CoordinatorSettings { } } } -/// `RetentionSettings` in the portable hive wire format. +/// Bounds on the records a coordinator retains; `None` means unbounded. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Default)] #[serde(deny_unknown_fields)] pub struct RetentionSettings { - /// Declared settled episodes. + /// Most recent finished episodes to keep. pub settled_episodes: Option, - /// Declared delivered. + /// Most recent acknowledged deliveries to keep. pub delivered: Option, - /// Declared interrupted. + /// Most recent interruption records to keep. pub interrupted: Option, - /// Declared pending per agent. + /// Most undelivered direct messages one agent may hold at once. pub pending_per_agent: Option, } diff --git a/crates/tinyhivemind-lang/src/lib.rs b/crates/tinyhivemind-lang/src/lib.rs index e6e4382d..ad534e87 100644 --- a/crates/tinyhivemind-lang/src/lib.rs +++ b/crates/tinyhivemind-lang/src/lib.rs @@ -1,13 +1,192 @@ -//! Declarative hive packages, validated and lowered without opening storage. +//! The hive language: a declarative, portable definition of a hive of agents, +//! validated and lowered into core values without opening storage. //! -//! Hosts supply strings to [`parse`], retain the immutable constitution, and -//! activate [`lower`] results between turns. This crate runs no agents. +//! A *package* is a map from relative path to file contents. Three canonical +//! JSON files carry the typed definition, and Markdown side documents carry +//! the prose: //! +//! | Path | Decoded into | Holds | +//! | --- | --- | --- | +//! | `hive.json` | [`Manifest`] | roles, seats, desks, goals, missions, topology, referrals, context paths, core policies, coordinator settings | +//! | `constitution.json` | [`Constitution`] | host-pinned caps, permitted edit classes, protected namespaces, canaries, opaque evaluator settings | +//! | `memory.json` | [`MemorySpec`] | memory identities and seat bindings, never remembered contents | +//! | `roles/*.md` | [`Package::documents`] | one charter per role | +//! | `seats/*.md` | [`Package::documents`] | one prompt per seat, with optional JSON frontmatter | +//! | `context/*.md` | [`Package::documents`] | shared documents split into stable `` items | +//! +//! # Editing a hive +//! +//! An orchestrator or editor agent changes a hive by editing its files +//! directly, the way a programmer edits source. The host then calls +//! [`check()`] with the original and edited document maps, and the crate acts +//! as the compiler: it runs every language rule and every constitution rule, +//! and either returns *every* violation as a located [`Diagnostic`] +//! (`hive.json:/policies/episode/turn_budget: forbidden_class: ...`) or a +//! [`Review`] holding the edit split into one [`patch::Patch`] per touched +//! edit class. Those patches are the recorded form the host keeps in +//! [`lineage`]; nobody has to author one. The `hive_check` example is the +//! same check as a command an agent runs after editing. +//! +//! # Entry points +//! +//! - [`check()`] and [`check_packages`] check a direct edit against the +//! original and the constitution, collecting every finding. +//! - [`diagnose`] and [`diagnose_documents`] collect every validation or +//! parse finding of one package, located by file and JSON Pointer. +//! - [`parse`] decodes a caller-supplied document map into a [`Package`] and +//! validates it. +//! - [`validate`] checks references, paths, policy bounds against the +//! constitution, frontmatter agreement, and memory reaches. +//! [`validate_path`] and [`validate_namespace`] expose its path and +//! namespace checks on their own. +//! - [`lower`] turns a valid package into a [`LoweredHive`]: core desks, +//! routing candidates, and per-seat factory inputs the host activates. +//! - [`patch::apply`], [`patch::guard`] and [`patch::diff`] apply, check and +//! compute the recorded form of an edit, a classified [`patch::Patch`]. +//! Memory learning operations, which are not files, are proposed this way. +//! - [`lineage::content_id`], [`lineage::record`], [`lineage::accepted_head`] +//! and [`lineage::pareto_frontier`] address evaluated designs by content and +//! fold an archive of them. +//! +//! # What this crate does not hold +//! +//! It reads no files: the host collects the document map and hands it to +//! [`parse`]. It runs no agents and calls no model: [`lower`] produces the +//! inputs a host needs to create seats, and the host decides when to activate +//! them. It owns no evaluation, archive storage, or memory store: those are +//! host-owned, and [`lineage`] only folds records the host supplies. It does +//! not convert to `OpenHuman` types either; that adapter lives in the +//! `tinyhivemind-openhuman` crate's `language` module, so this crate stays +//! host-neutral. +//! +//! # Example +//! +//! ``` +//! use std::collections::BTreeMap; +//! use tinyhivemind_lang::{CoordinatorSettings, Policies, lower, parse}; +//! +//! // Every core policy is spelled out on the wire; this example takes the +//! // defaults rather than repeating them. +//! let hive = serde_json::json!({ +//! "$schema": "https://tinyhumans.ai/schemas/hive/v1", +//! "version": 1, +//! "id": "review", +//! "name": "Review board", +//! "roles": [{ "id": "reviewer", "charter": "roles/reviewer.md" }], +//! "seats": [{ +//! "id": "reviewer", +//! "label": "Reviewer", +//! "role": "reviewer", +//! "prompt": "seats/reviewer.md", +//! "template": "default", +//! "config": {}, +//! "model": null, +//! "tools": { "allow": ["file_read"], "deny": [] }, +//! "mcp": [], +//! "skills": [] +//! }], +//! "desks": [{ +//! "id": "board", +//! "name": "Board", +//! "description": "review one change", +//! "members": ["reviewer"], +//! "responder_mode": "auto" +//! }], +//! "goals": [], +//! "missions": [], +//! "topology": [], +//! "referrals": [], +//! "context": [], +//! "policies": Policies::default(), +//! "coordinator": CoordinatorSettings::default(), +//! }); +//! let constitution = serde_json::json!({ +//! "hard_constraints": [], +//! "max_turns": 60, +//! "max_width": 8, +//! "permitted_edits": [], +//! "read_only_memories": [], +//! "protected_namespaces": [], +//! "contamination_canaries": [], +//! "evaluation": null, +//! "acceptance": null, +//! "telemetry": null +//! }); +//! let memory = serde_json::json!({ "root": "team:hive", "identities": [], "bindings": [] }); +//! +//! let documents = BTreeMap::from([ +//! ("hive.json".to_owned(), hive.to_string()), +//! ("constitution.json".to_owned(), constitution.to_string()), +//! ("memory.json".to_owned(), memory.to_string()), +//! ("roles/reviewer.md".to_owned(), "Judge changes on evidence.\n".to_owned()), +//! ( +//! "seats/reviewer.md".to_owned(), +//! "---\n{\"id\":\"reviewer\",\"role\":\"reviewer\"}\n---\nCite a line for every finding.\n" +//! .to_owned(), +//! ), +//! ]); +//! +//! let package = parse(&documents)?; +//! let hive = lower(&package)?; +//! let seats: Vec<_> = hive.seats.iter().map(|seat| seat.id.as_str()).collect(); +//! assert_eq!(seats, ["reviewer"]); +//! // Lowering hands the host the prompt body without its frontmatter. +//! assert_eq!(hive.seats[0].prompt, "Cite a line for every finding.\n"); +//! assert_eq!(hive.desks[0].members, ["reviewer"]); +//! # Ok::<(), tinyhivemind_lang::Error>(()) //! ``` -//! use tinyhivemind_lang::{validate, Package}; -//! let package = Package::default(); -//! assert!(validate(&package).is_ok()); +//! +//! # Example: checking a direct edit +//! +//! ``` +//! use std::collections::BTreeMap; +//! use tinyhivemind_lang::{Package, Role, Rule, Seat, check, patch::PatchClass}; +//! +//! let mut package = Package::default(); +//! package.constitution.permitted_edits = vec![PatchClass::Prompt]; +//! package.manifest.roles.push(Role { id: "writer".into(), charter: "roles/writer.md".into() }); +//! package.manifest.seats.push(Seat { +//! id: "writer".into(), +//! label: "Writer".into(), +//! role: "writer".into(), +//! prompt: "seats/writer.md".into(), +//! template: "default".into(), +//! ..Seat::default() +//! }); +//! let mut original = BTreeMap::from([ +//! ("roles/writer.md".to_owned(), "Write clearly.\n".to_owned()), +//! ("seats/writer.md".to_owned(), "Draft the summary.\n".to_owned()), +//! ]); +//! original.insert("hive.json".into(), serde_json::to_string_pretty(&package.manifest)?); +//! original.insert("constitution.json".into(), serde_json::to_string_pretty(&package.constitution)?); +//! original.insert("memory.json".into(), serde_json::to_string_pretty(&package.memory)?); +//! +//! // The editor rewrites the seat prompt file. The check accepts it as one +//! // prompt-class change. +//! let mut edited = original.clone(); +//! edited.insert("seats/writer.md".into(), "Draft the summary and cite sources.\n".into()); +//! let review = check(&original, &edited).expect("a permitted prompt edit"); +//! assert_eq!(review.changes.len(), 1); +//! assert_eq!(review.changes[0].class, PatchClass::Prompt); +//! +//! // Raising its own turn cap in the constitution is refused, located at the +//! // field it changed. +//! let mut tampered = original.clone(); +//! tampered.insert( +//! "constitution.json".into(), +//! original["constitution.json"].replace("\"max_turns\": 60", "\"max_turns\": 600"), +//! ); +//! let diagnostics = check(&original, &tampered).expect_err("the constitution is immutable"); +//! assert_eq!(diagnostics[0].rule, Rule::Immutable); +//! assert_eq!(diagnostics[0].file, "constitution.json"); +//! assert_eq!( +//! diagnostics[0].to_string(), +//! "constitution.json:/max_turns: immutable: the constitution is host-owned and no edit may change it" +//! ); +//! # Ok::<(), serde_json::Error>(()) //! ``` +pub mod check; +pub mod diagnostic; pub mod document; pub mod error; pub mod lineage; @@ -15,8 +194,10 @@ pub mod lowering; pub mod memory; pub mod patch; pub mod validation; +pub use check::{Review, check, check_packages}; +pub use diagnostic::{Diagnostic, Rule}; pub use document::*; pub use error::{Error, Result}; pub use lowering::{LoweredHive, LoweredMemoryBinding, LoweredSeat, lower}; pub use memory::*; -pub use validation::validate; +pub use validation::{diagnose, validate, validate_namespace, validate_path}; diff --git a/crates/tinyhivemind-lang/src/lowering/README.md b/crates/tinyhivemind-lang/src/lowering/README.md index 8258690e..f2e00cc8 100644 --- a/crates/tinyhivemind-lang/src/lowering/README.md +++ b/crates/tinyhivemind-lang/src/lowering/README.md @@ -7,6 +7,6 @@ Lowering preserves memory constraints for host enforcement and never starts agen | File | Purpose | | --- | --- | -| types.rs | Portable wire types and their documented fields. | +| types.rs | `LoweredHive`, `LoweredSeat` and `LoweredMemoryBinding`. | | mod.rs | Public lowering API and implementation. | | test.rs | Complete activation snapshot preservation regression. | diff --git a/crates/tinyhivemind-lang/src/lowering/types.rs b/crates/tinyhivemind-lang/src/lowering/types.rs index e5a4a254..dc51cb86 100644 --- a/crates/tinyhivemind-lang/src/lowering/types.rs +++ b/crates/tinyhivemind-lang/src/lowering/types.rs @@ -39,7 +39,8 @@ pub struct LoweredHive { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct LoweredMemoryBinding { - /// Resolved memory namespace identifying the agent or pool. + /// The namespace's final id: the text after its last `:`, so + /// `team:hive/agent:critic` yields `critic`. pub agent_id: String, /// Full resolved namespace retained for host capability checks. pub namespace: String, @@ -47,7 +48,9 @@ pub struct LoweredMemoryBinding { pub kind: crate::MemoryKind, /// Identity lifecycle, freeze and fork requirements. pub identity: crate::MemoryIdentity, - /// Root namespace supplied to the runtime. + /// The namespace's parent: the text before its last `/`, so + /// `team:hive/agent:critic` yields `team:hive`. A namespace with no `/` + /// yields [`crate::MemorySpec::root`]. pub root: String, /// Recall, remember and read settings the host must install. pub settings: SeatMemory, diff --git a/crates/tinyhivemind-lang/src/memory/README.md b/crates/tinyhivemind-lang/src/memory/README.md index 6c4f1c2e..a735b0f4 100644 --- a/crates/tinyhivemind-lang/src/memory/README.md +++ b/crates/tinyhivemind-lang/src/memory/README.md @@ -1,13 +1,18 @@ # memory -Defines portable memory identities, lifecycle and seat read/remember requirements. Stored entries remain owned by the host memory engine. +Wire types for `memory.json` (`MemorySpec`: identities, lifecycles, and seat +read/remember bindings) and `constitution.json` (`Constitution`: host-pinned +caps, permitted edit classes, protected namespaces, read-only pins, canaries, +and opaque evaluator settings). Stored entries remain owned by the host memory +engine. | File | Purpose | | --- | --- | -| types.rs | Portable wire types and their documented fields. | -| mod.rs | Public memory API and implementation. | +| `mod.rs` | Module documentation and re-exports. | +| `types.rs` | `Constitution`, `MemorySpec`, `MemoryIdentity`, `SeatMemory`, `MemoryReach`, and their enums. | Read reaches use explicit namespaces within the package root. `inherit` must be false because engine ancestor inheritance includes the global root; list permitted ancestors as separate reaches instead. Hosts enforce read-only, recall budgets, remembered categories and fork lifecycle from lowering. +Validation of these types lives in `../validation`. diff --git a/crates/tinyhivemind-lang/src/memory/mod.rs b/crates/tinyhivemind-lang/src/memory/mod.rs index 4f85f8c4..a07d053a 100644 --- a/crates/tinyhivemind-lang/src/memory/mod.rs +++ b/crates/tinyhivemind-lang/src/memory/mod.rs @@ -1,3 +1,11 @@ -//! Memory declarations contain bindings and limits, never remembered content. +//! Memory and constitution declarations: where a hive's memory lives, who may +//! read and write it, and the host-pinned caps every package is judged by. +//! +//! [`MemorySpec`] is decoded from `memory.json` and [`Constitution`] from +//! `constitution.json`. Both describe bindings and limits only: remembered +//! entries stay in the host's memory engine, and nothing here opens it. +//! [`crate::validate`] checks namespaces, reaches, and read-only pins, and +//! [`crate::lower`] hands each seat its resolved binding for the host to +//! enforce. mod types; pub use types::*; diff --git a/crates/tinyhivemind-lang/src/memory/types.rs b/crates/tinyhivemind-lang/src/memory/types.rs index 2e714088..c907bbf7 100644 --- a/crates/tinyhivemind-lang/src/memory/types.rs +++ b/crates/tinyhivemind-lang/src/memory/types.rs @@ -1,30 +1,45 @@ -//! Memory declarations contain bindings and limits, never remembered content. +//! Wire types for `constitution.json` and `memory.json`. use crate::patch::PatchClass; use serde::{Deserialize, Serialize}; use serde_json::Value; -/// Constitution in the portable hive wire format. +/// The host-pinned constitution decoded from `constitution.json`. +/// +/// It caps what the manifest may ask for and fixes the rules editor proposals +/// are judged by. [`crate::patch::guard`] rejects any proposal whose candidate +/// changes it ([`crate::patch::Error::Immutable`]). The default caps are 60 +/// turns and a width of 8, with no edit class permitted. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Constitution { - /// Hard requirements the host evaluator must enforce. + /// Requirements stated in prose, opaque to this crate; the host evaluator + /// enforces them. pub hard_constraints: Vec, - /// Maximum turn wall any package policy may request. + /// Ceiling for the episode `turn_budget` and every conduct `turn_wall`; + /// must be nonzero. pub max_turns: u64, - /// Maximum concurrent width any package policy may request. + /// Ceiling for every round width the manifest declares (episode revealed + /// width, routing, division and coordinator); must be nonzero. pub max_width: usize, - /// Edit classes the host permits for editor proposals. + /// Patch classes an editor proposal may use; a proposal of any other class + /// fails with [`crate::patch::Error::ForbiddenClass`]. pub permitted_edits: Vec, - /// Identity IDs pinned read-only regardless of patch class. + /// Memory identity ids pinned read-only. Each must name a declared + /// identity whose `read_only` is true, and no proposal may change that + /// identity, its bindings, or learn into it. pub read_only_memories: Vec, - /// Evaluation or orchestrator namespace prefixes seats cannot access. + /// Namespaces no memory identity or read reach may name, sit under, or + /// (with `descendants`) reach down into; for example an evaluator's + /// held-out store. pub protected_namespaces: Vec, - /// Held-out text markers forbidden in learning deltas. + /// Strings that must not appear anywhere in a proposed manifest, memory + /// spec, document, or patch; a match fails with + /// [`crate::patch::Error::Contamination`]. pub contamination_canaries: Vec, - /// Host evaluator configuration, immutable to editors. + /// Evaluator configuration, opaque to this crate and enforced by the host. pub evaluation: Value, - /// Host acceptance configuration, immutable to editors. + /// Acceptance configuration, opaque to this crate and enforced by the host. pub acceptance: Value, - /// Host logging configuration, immutable to editors. + /// Telemetry configuration, opaque to this crate and enforced by the host. pub telemetry: Value, } impl Default for Constitution { @@ -43,15 +58,20 @@ impl Default for Constitution { } } } -/// `MemorySpec` in the portable hive wire format. +/// The memory declaration decoded from `memory.json`. +/// +/// It names where memory lives and who may read or write it. It never holds +/// remembered entries; those stay in the host's memory engine. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct MemorySpec { - /// Namespace owning this package memory tree. + /// Namespace that owns the package's memory tree, for example + /// `team:hive`. Every identity namespace and read reach must equal it or + /// sit below it. pub root: String, - /// Named agent or shared pool memories, without stored entries. + /// Declared memories, private to an agent or shared as a pool. pub identities: Vec, - /// Seat assignments, read reaches and recall budgets. + /// Per-seat bindings; a seat has at most one. pub bindings: Vec, } impl Default for MemorySpec { @@ -63,91 +83,110 @@ impl Default for MemorySpec { } } } -/// `MemoryIdentity` in the portable hive wire format. +/// One declared memory: a namespace, who owns it, and how long it lives. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct MemoryIdentity { - /// Stable memory identity independent of seat identity. + /// The memory's identity, unique among identities and independent of any + /// seat id; bindings, read-only pins, forks, and learning refer to it. pub id: String, - /// Qualified memory namespace using engine-compatible syntax. + /// Full namespace path, unique among identities, in the syntax + /// [`crate::validate_namespace`] accepts, at or below + /// [`MemorySpec::root`], and outside every protected namespace. An + /// [`MemoryKind::Agent`] namespace must differ from the root and end in + /// an `agent:` segment. + /// + /// Lowering derives [`crate::LoweredMemoryBinding::agent_id`] from the + /// text after the last `:` and [`crate::LoweredMemoryBinding::root`] from + /// the text before the last `/` (the spec root when there is none). pub namespace: String, - /// Whether this identity is private agent memory or a shared pool. + /// Whether this is one agent's private memory or a shared pool. pub kind: MemoryKind, - /// Whether the host retains memory for one run or across runs. + /// Whether the memory lasts for one run or for the life of the hive. pub lifecycle: MemoryLifecycle, - /// Whether the host must refuse remembered writes. + /// Whether the host must refuse writes to this memory; must be true when + /// the constitution pins the identity read-only. pub read_only: bool, - /// Optional identity whose namespace this child forks. + /// Identity this memory forks from; it must be declared, and this + /// namespace must sit below the parent's. pub fork_parent: Option, } -/// `SeatMemory` in the portable hive wire format. +/// One seat's memory binding: what it writes to, reads, and when it recalls. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct SeatMemory { - /// Stable seat identity receiving this binding. + /// Identity of the bound seat; must name a declared seat, and no other + /// binding may name it. pub seat: String, - /// Declared memory identity bound to the seat. + /// Identity of the [`MemoryIdentity`] the seat writes to; must be declared. pub identity: String, - /// Bounded namespace reaches available during recall. + /// Namespaces the seat may recall from, each bounded within the root. pub reads: Vec, - /// Session lifecycle events at which the host recalls memory. + /// The moments at which the host recalls memory into the seat's session. pub recall_at: Vec, - /// Maximum characters the host may recall; nonzero. + /// Most characters one recall may return; must be nonzero. pub budget_chars: usize, - /// Entry categories the host may store after activations. + /// Kinds of entry the host may store for the seat after an activation. pub remember: Vec, } -/// `MemoryReach` in the portable hive wire format. +/// One namespace a seat may recall from. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Default)] #[serde(deny_unknown_fields)] pub struct MemoryReach { - /// Exact namespace at which the read begins. + /// Namespace the read starts at; at or below [`MemorySpec::root`] and + /// outside every protected namespace. pub at: String, - /// Ancestor inheritance flag; packages require false to exclude global root. - /// List each permitted ancestor as an explicit bounded reach instead. + /// Whether ancestors of `at` are read too. Must be false: engine + /// inheritance would include the global root. List each permitted + /// ancestor as its own reach instead. pub inherit: bool, - /// Whether descendant namespaces participate in recall. + /// Whether namespaces below `at` are read too. When true, no protected + /// namespace may sit below `at`. pub descendants: bool, } -/// Portable `MemoryKind` choice. +/// Who a memory belongs to. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum MemoryKind { - /// Agent behavior. + /// One agent's private memory, at an `agent:` namespace. Agent, - /// Pool behavior. + /// A pool several seats share. Pool, } -/// Portable `MemoryLifecycle` choice. +/// How long a memory lasts. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum MemoryLifecycle { - /// Run behavior. + /// Scoped to one run; it is not part of the evaluated design. Run, - /// Hive behavior. + /// Persists across runs, so it is part of the evaluated design: + /// [`crate::lineage::content_id`] requires a watermark for it. Hive, } -/// Portable `RecallMoment` choice. +/// A moment in a seat's session at which the host recalls memory. +/// +/// These mirror the core runtime's recall moments, without their payloads. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum RecallMoment { - /// `SessionStart` behavior. + /// The seat's session is opened for the first time in a run. SessionStart, - /// Rejoin behavior. + /// The seat rejoins a session it already holds, after peers worked + /// without it. Rejoin, - /// Compaction behavior. + /// The host compacted the seat's session and recalls what was dropped. Compaction, } -/// Portable `EntryKind` choice. +/// A kind of entry a seat may leave in memory, mirroring the core runtime's. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum EntryKind { - /// Observation behavior. + /// Something the seat observed, such as a command and its output. Observation, - /// `FailedAttempt` behavior. + /// An approach that did not work, so nobody tries it again blind. FailedAttempt, - /// Outcome behavior. + /// A result the seat reached. Outcome, - /// Note behavior. + /// Anything else worth keeping. Note, } diff --git a/crates/tinyhivemind-lang/src/patch/README.md b/crates/tinyhivemind-lang/src/patch/README.md index acdb2013..a9ba4d81 100644 --- a/crates/tinyhivemind-lang/src/patch/README.md +++ b/crates/tinyhivemind-lang/src/patch/README.md @@ -1,5 +1,10 @@ # Editor proposals +A `Patch` is the recorded form of an edit. Editors usually write files and +the host runs `crate::check`, which derives one patch per touched class with +the helpers in `derive.rs`. A host may still propose a patch directly and +call `apply`, and must for memory learning operations, which are not files. + The host proposes and calls `apply`; a private candidate is guarded and validated before being returned. Host learning operations require a separate atomic memory transaction checking the supplied watermark and previous entry. @@ -9,6 +14,7 @@ The library opens no store and never embeds learned entries in the package. | --- | --- | | `mod.rs` | Typed JSON Pointer edits, diff, atomic application and full candidate guard | | `types.rs` | Patch classes, preconditioned operations, results and errors | +| `derive.rs` | Operations between two package states and the class owning each, shared by `diff` and `check` | | `items.rs` | Stable `` Markdown deltas and prompt body boundaries | | `test.rs` | Deterministic wire, diff/apply and guard regressions | diff --git a/crates/tinyhivemind-lang/src/patch/derive.rs b/crates/tinyhivemind-lang/src/patch/derive.rs new file mode 100644 index 00000000..90d4b9f6 --- /dev/null +++ b/crates/tinyhivemind-lang/src/patch/derive.rs @@ -0,0 +1,128 @@ +//! Derive operations from two package states, and name the class owning each. +//! +//! [`super::diff`] and [`crate::check()`] share these: the operations for one +//! changed manifest or memory field, one created or retired document, and one +//! changed document body or context item. + +use super::{Operation, PatchClass, Result, boundary, items}; +use serde_json::Value; + +/// Every edit class, in the order derived changes are recorded. +pub(crate) const CLASSES: [PatchClass; 7] = [ + PatchClass::Prompt, + PatchClass::Context, + PatchClass::Memory, + PatchClass::Roster, + PatchClass::Topology, + PatchClass::Policy, + PatchClass::Goal, +]; + +/// The class whose boundary admits `operation`, if any class does. +pub(crate) fn owner(operation: &Operation) -> Option { + CLASSES + .into_iter() + .find(|class| boundary(*class, operation)) +} + +/// One `json` operation per differing top-level field of `root` (`manifest` +/// or `memory`) between two whole-package wire values. +pub(crate) fn fields(old: &Value, new: &Value, root: &str) -> Vec { + let empty = serde_json::Map::new(); + let old_fields = old[root].as_object().unwrap_or(&empty); + let new_fields = new[root].as_object().unwrap_or(&empty); + let keys: std::collections::BTreeSet<_> = old_fields.keys().chain(new_fields.keys()).collect(); + keys.into_iter() + .filter(|key| old_fields.get(*key) != new_fields.get(*key)) + .map(|key| Operation::Json { + pointer: format!("/{root}/{key}"), + before: old_fields.get(key).cloned(), + after: new_fields.get(key).cloned(), + }) + .collect() +} + +/// The `document` operation creating or retiring `path`, if exactly one side +/// holds it. +pub(crate) fn lifecycle( + path: &str, + before: Option<&String>, + after: Option<&String>, +) -> Option { + (before.is_some() != after.is_some()).then(|| Operation::Document { + path: path.to_owned(), + before: before.cloned(), + after: after.cloned(), + }) +} + +/// The operations rewriting an existing document from `before` to `after`: +/// one `context` operation per changed item under `context/`, otherwise one +/// `prompt` operation for the body. +/// +/// # Errors +/// Rejects malformed context markers and a changed frontmatter +/// ([`super::Error::Immutable`]). +pub(crate) fn modification(path: &str, before: &str, after: &str) -> Result> { + if before == after { + return Ok(vec![]); + } + if path.starts_with("context/") { + let old_items = items::items(before)?; + let new_items = items::items(after)?; + // Document order: existing items first, then appended ones as written. + let mut keys: Vec<&String> = Vec::new(); + for (id, _, _, _) in old_items.iter().chain(&new_items) { + if !keys.contains(&id) { + keys.push(id); + } + } + let body = |list: &[(String, usize, usize, String)], id: &String| { + list.iter() + .find(|(key, _, _, _)| key == id) + .map(|(_, _, _, body)| body.clone()) + }; + return Ok(keys + .into_iter() + .filter_map(|id| { + let (before, after) = (body(&old_items, id), body(&new_items, id)); + (before != after).then(|| Operation::Context { + path: path.to_owned(), + item: id.clone(), + before, + after, + }) + }) + .collect()); + } + let old_offset = items::body_offset(before); + let new_offset = items::body_offset(after); + if before[..old_offset] != after[..new_offset] { + return Err(super::Error::Immutable); + } + Ok(vec![Operation::Prompt { + path: path.to_owned(), + before: before[old_offset..].into(), + after: after[new_offset..].into(), + }]) +} + +/// Replay `operations` (context item deltas) on `document`. +/// +/// # Errors +/// Rejects an item edit [`super::apply`] would also reject. +pub(crate) fn replay_items(document: &str, operations: &[&Operation]) -> Result { + let mut document = document.to_owned(); + for operation in operations { + if let Operation::Context { + item, + before, + after, + .. + } = operation + { + document = items::edit(&document, item, before.as_deref(), after.as_deref())?; + } + } + Ok(document) +} diff --git a/crates/tinyhivemind-lang/src/patch/mod.rs b/crates/tinyhivemind-lang/src/patch/mod.rs index b334e6a7..29e8ebb9 100644 --- a/crates/tinyhivemind-lang/src/patch/mod.rs +++ b/crates/tinyhivemind-lang/src/patch/mod.rs @@ -1,8 +1,10 @@ //! Atomic editor proposals, stable Markdown deltas, and immutable host guards. +mod derive; mod items; mod types; use crate::Package; +pub(crate) use derive::{CLASSES, fields, lifecycle, modification, owner, replay_items}; use serde_json::Value; pub use types::{AppliedPatch, Error, Operation, Patch, PatchClass, Result}; @@ -60,7 +62,9 @@ fn boundary(class: PatchClass, op: &Operation) -> bool { } } -fn protected(value: &Value) -> std::collections::BTreeMap { +/// Every protected key's JSON Pointer and value: `evaluation`, `telemetry`, +/// `logging` and `constitution` anywhere, and `acceptance` outside `goals`. +pub(crate) fn protected(value: &Value) -> std::collections::BTreeMap { fn visit( value: &Value, path: &str, @@ -377,84 +381,23 @@ pub fn guard(original: &Package, proposed: &Package, patch: &Patch) -> Result<() pub fn diff(original: &Package, proposed: &Package, class: PatchClass) -> Result { let old = serde_json::to_value(original)?; let new = serde_json::to_value(proposed)?; - let mut operations = Vec::new(); - for root in ["manifest", "memory"] { - let old_fields = old[root] - .as_object() - .ok_or_else(|| Error::Pointer(root.into()))?; - let new_fields = new[root] - .as_object() - .ok_or_else(|| Error::Pointer(root.into()))?; - let keys: std::collections::BTreeSet<_> = - old_fields.keys().chain(new_fields.keys()).collect(); - for key in keys { - if old_fields.get(key) != new_fields.get(key) { - operations.push(Operation::Json { - pointer: format!("/{root}/{key}"), - before: old_fields.get(key).cloned(), - after: new_fields.get(key).cloned(), - }); - } - } - } + let mut operations = derive::fields(&old, &new, "manifest"); + operations.extend(derive::fields(&old, &new, "memory")); let paths: std::collections::BTreeSet<_> = original .documents .keys() .chain(proposed.documents.keys()) .collect(); - for path in paths { - if original.documents.contains_key(path) != proposed.documents.contains_key(path) { - operations.push(Operation::Document { - path: path.clone(), - before: original.documents.get(path).cloned(), - after: proposed.documents.get(path).cloned(), - }); - } - } + operations.extend(paths.into_iter().filter_map(|path| { + derive::lifecycle( + path, + original.documents.get(path), + proposed.documents.get(path), + ) + })); for (path, before) in &original.documents { - let Some(after) = proposed.documents.get(path) else { - continue; - }; - if before == after { - continue; - } - if path.starts_with("context/") { - let old_items = items::items(before)?; - let new_items = items::items(after)?; - let keys: std::collections::BTreeSet<_> = old_items - .iter() - .chain(&new_items) - .map(|(id, _, _, _)| id) - .collect(); - for id in keys { - let before = old_items - .iter() - .find(|(key, _, _, _)| key == id) - .map(|(_, _, _, body)| body.clone()); - let after = new_items - .iter() - .find(|(key, _, _, _)| key == id) - .map(|(_, _, _, body)| body.clone()); - if before != after { - operations.push(Operation::Context { - path: path.clone(), - item: id.clone(), - before, - after, - }); - } - } - } else { - let old_offset = items::body_offset(before); - let new_offset = items::body_offset(after); - if before[..old_offset] != after[..new_offset] { - return Err(Error::Immutable); - } - operations.push(Operation::Prompt { - path: path.clone(), - before: before[old_offset..].into(), - after: after[new_offset..].into(), - }); + if let Some(after) = proposed.documents.get(path) { + operations.extend(derive::modification(path, before, after)?); } } let patch = Patch { class, operations }; diff --git a/crates/tinyhivemind-lang/src/patch/test.rs b/crates/tinyhivemind-lang/src/patch/test.rs index 3d7bbc9e..0fe075ac 100644 --- a/crates/tinyhivemind-lang/src/patch/test.rs +++ b/crates/tinyhivemind-lang/src/patch/test.rs @@ -100,6 +100,17 @@ fn prompt_and_context_diff_apply_preserve_neighbors_and_preamble() { ); } +#[test] +fn appended_context_items_keep_document_order() { + let original = package(); + let mut proposed = original.clone(); + let notes = proposed.documents.get_mut("context/notes.md").unwrap(); + notes.push_str("\nLast\n\nEarlier id\n"); + let proposal = diff(&original, &proposed, PatchClass::Context).unwrap(); + assert_eq!(proposal.operations.len(), 2); + assert_eq!(apply(&original, &proposal).unwrap().candidate, proposed); +} + #[test] fn policy_json_diff_round_trips_and_stale_edits_are_atomic() { let original = package(); diff --git a/crates/tinyhivemind-lang/src/validation/README.md b/crates/tinyhivemind-lang/src/validation/README.md index 1245f0ad..5b4ab14a 100644 --- a/crates/tinyhivemind-lang/src/validation/README.md +++ b/crates/tinyhivemind-lang/src/validation/README.md @@ -1,8 +1,21 @@ # validation -Checks references, paths, schema version, bounded policies and constitution memory restrictions before activation. Desk graphs may contain cycles; goal decompositions may not. +Checks references, paths, schema version, bounded policies and constitution +memory restrictions before activation. Desk graphs may contain cycles; goal +decompositions may not. + +Every rule pushes its finding with the file and JSON Pointer it concerns. +`validate` returns the first as an `Error`; `diagnose` returns every one as a +`Diagnostic`. Rules run in one fixed order, so the first finding is the error +fail-fast validation stopped at. A seat's role is reported at +`hive.json:/seats//role`, a missing prompt at the manifest field naming it, +frontmatter and context markers on the Markdown file, memory findings in +`memory.json`, and protected namespaces and read-only pins in +`constitution.json`. | File | Purpose | | --- | --- | -| mod.rs | Public validation API and implementation. | -| test.rs | Wire contracts and validation regressions. | +| `mod.rs` | `validate`, `diagnose`, `validate_path`, `validate_namespace`, and the manifest rules | +| `bounds.rs` | Policy and coordinator bounds against the constitution's caps | +| `memory.rs` | Memory identities, bindings and reaches | +| `test/` | Fail-fast regressions and finding locations; see [`test/README.md`](test/README.md) | diff --git a/crates/tinyhivemind-lang/src/validation/bounds.rs b/crates/tinyhivemind-lang/src/validation/bounds.rs new file mode 100644 index 00000000..7df1fcc2 --- /dev/null +++ b/crates/tinyhivemind-lang/src/validation/bounds.rs @@ -0,0 +1,205 @@ +//! Policy and coordinator bounds against the constitution's caps. +//! +//! Each failing bound is located at the `hive.json` field an editor would +//! change, except a zero cap, which lives in `constitution.json`. +use crate::{ + Error, Package, + diagnostic::{CONSTITUTION, Findings, HIVE}, +}; + +/// Record a failed bound named `field` at `pointer` in `file`. +fn bound(found: &mut Findings, field: &str, valid: bool, file: &str, pointer: &str) { + if !valid { + found.push( + Error::Bound { + field: field.into(), + }, + file, + pointer.to_owned(), + ); + } +} + +fn widen(width: usize) -> u64 { + u64::try_from(width).unwrap_or(u64::MAX) +} + +/// Every bound, in fail-fast order. +pub(super) fn bounds(package: &Package, found: &mut Findings) { + let p = &package.manifest.policies; + let c = &package.constitution; + let cap = if c.max_turns == 0 { + "/max_turns" + } else { + "/max_width" + }; + bound( + found, + "constitution", + c.max_turns > 0 && c.max_width > 0, + CONSTITUTION, + cap, + ); + bound( + found, + "episode.turn_budget", + p.episode.turn_budget > 0 && u64::from(p.episode.turn_budget) <= c.max_turns, + HIVE, + "/policies/episode/turn_budget", + ); + let width = if p.episode.round_width > 0 && p.episode.round_width <= p.episode.revealed_width { + "/policies/episode/revealed_width" + } else { + "/policies/episode/round_width" + }; + bound( + found, + "episode.width", + p.episode.round_width > 0 + && p.episode.round_width <= p.episode.revealed_width + && u64::from(p.episode.revealed_width) <= widen(c.max_width), + HIVE, + width, + ); + bound( + found, + "episode.dominance_cap", + p.episode.dominance_cap <= 100, + HIVE, + "/policies/episode/dominance_cap", + ); + rounds(package, found); + policies(package, found); +} + +/// Division, routing, coordinator and conduct widths and walls. +fn rounds(package: &Package, found: &mut Findings) { + let p = &package.manifest.policies; + let c = &package.constitution; + let settings = &package.manifest.coordinator; + bound( + found, + "division.round_width", + p.division.round_width > 0 && u64::from(p.division.round_width) <= widen(c.max_width), + HIVE, + "/policies/division/round_width", + ); + bound( + found, + "routing", + p.routing.round_width > 0 + && p.routing.round_width <= c.max_width + && p.routing.choice_option_limit > 0, + HIVE, + "/policies/routing", + ); + bound( + found, + "routing.probability", + [ + p.routing.minimum_confidence, + p.routing.high_impact_minimum_confidence, + p.routing.clarification_threshold, + p.routing.high_impact_threshold, + ] + .iter() + .all(|value| value.parts() <= 1_000_000), + HIVE, + "/policies/routing", + ); + bound( + found, + "coordinator.round_width", + settings.round_width > 0 && settings.round_width <= c.max_width, + HIVE, + "/coordinator/round_width", + ); + for (policy, pointer) in [ + (&p.conduct, "/policies/conduct"), + (&settings.conduct_policy, "/coordinator/conduct_policy"), + ] { + bound( + found, + "conduct", + policy.child_turn_wall > 0 + && policy.turn_wall > 0 + && policy.child_turn_wall <= policy.turn_wall + && policy.turn_wall <= c.max_turns, + HIVE, + pointer, + ); + } + bound( + found, + "broadcast_budget", + settings.broadcast_budget != Some(0), + HIVE, + "/coordinator/broadcast_budget", + ); +} + +/// The remaining policies' nonzero budgets and enabled-feature caps. +fn policies(package: &Package, found: &mut Findings) { + let p = &package.manifest.policies; + bound( + found, + "digest", + p.digest.input_limit > 0 && p.digest.budget_chars > 0, + HIVE, + "/policies/digest", + ); + bound( + found, + "aside", + !p.aside.enabled || (p.aside.max_members > 0 && p.aside.max_messages > 0), + HIVE, + "/policies/aside", + ); + bound( + found, + "quorum", + p.episode.quorum.threshold > 0 && p.episode.quorum.window > 0, + HIVE, + "/policies/episode/quorum", + ); + bound( + found, + "episode.repetition_cap", + p.episode.repetition_cap > 0, + HIVE, + "/policies/episode/repetition_cap", + ); + let half_life = if p.episode.weights.half_life == 0 { + "/policies/episode/weights/half_life" + } else { + "/policies/directory/half_life" + }; + bound( + found, + "half_life", + p.episode.weights.half_life > 0 && p.directory.half_life > 0, + HIVE, + half_life, + ); + bound( + found, + "referral", + !p.referral.enabled || p.referral.max_hops > 0, + HIVE, + "/policies/referral", + ); + bound( + found, + "brevity", + p.brevity.message_chars > 0 && p.brevity.window > 0, + HIVE, + "/policies/brevity", + ); + bound( + found, + "exchange", + !p.exchange.enabled || (p.exchange.contact_cap > 0 && p.exchange.round_cap > 0), + HIVE, + "/policies/exchange", + ); +} diff --git a/crates/tinyhivemind-lang/src/validation/memory.rs b/crates/tinyhivemind-lang/src/validation/memory.rs new file mode 100644 index 00000000..c13db22a --- /dev/null +++ b/crates/tinyhivemind-lang/src/validation/memory.rs @@ -0,0 +1,178 @@ +//! Memory identities, bindings and reaches against the constitution. +//! +//! Findings name `memory.json`, except the protected namespaces and +//! read-only pins, which the constitution declares. +use super::{identities, reference, validate_namespace}; +use crate::{ + Error, Package, Result, + diagnostic::{CONSTITUTION, Findings, MEMORY}, +}; +use std::collections::BTreeSet; + +fn protected(package: &Package, namespace: &str, inherit: bool, descendants: bool) -> Result<()> { + validate_namespace(namespace)?; + if package.constitution.protected_namespaces.iter().any(|p| { + namespace == p + || namespace + .strip_prefix(p) + .is_some_and(|tail| tail.starts_with('/')) + || (descendants + && p.strip_prefix(namespace) + .is_some_and(|tail| tail.starts_with('/'))) + || (inherit && namespace.starts_with(&format!("{p}/"))) + }) { + return Err(Error::MemoryAccess { + namespace: namespace.into(), + }); + } + Ok(()) +} + +fn access(namespace: &str) -> Error { + Error::MemoryAccess { + namespace: namespace.into(), + } +} + +/// Whether `namespace` is `root` or sits below it. +fn within(namespace: &str, root: &str) -> bool { + namespace == root || namespace.starts_with(&format!("{root}/")) +} + +pub(super) fn memory(package: &Package, seats: &BTreeSet<&str>, found: &mut Findings) { + let spec = &package.memory; + found.check(validate_namespace(&spec.root), MEMORY, "/root".to_owned()); + let ids = identities( + found, + MEMORY, + "memory", + spec.identities.iter().map(|v| &v.id), + |i| format!("/identities/{i}/id"), + ); + identities( + found, + MEMORY, + "memory namespace", + spec.identities.iter().map(|v| &v.namespace), + |i| format!("/identities/{i}/namespace"), + ); + identities( + found, + MEMORY, + "memory binding", + spec.bindings.iter().map(|v| &v.seat), + |i| format!("/bindings/{i}/seat"), + ); + for (i, namespace) in package.constitution.protected_namespaces.iter().enumerate() { + found.check( + validate_namespace(namespace), + CONSTITUTION, + format!("/protected_namespaces/{i}"), + ); + } + for (i, id) in package.constitution.read_only_memories.iter().enumerate() { + found.check( + reference("memory", id, &ids), + CONSTITUTION, + format!("/read_only_memories/{i}"), + ); + } + declared(package, &ids, found); + bound_seats(package, seats, &ids, found); +} + +/// Each identity's namespace, kind, fork parent and read-only pin. +fn declared(package: &Package, ids: &BTreeSet<&str>, found: &mut Findings) { + let spec = &package.memory; + for (i, identity) in spec.identities.iter().enumerate() { + let at = format!("/identities/{i}/namespace"); + let namespace = &identity.namespace; + if !found.check( + protected(package, namespace, false, false), + MEMORY, + at.clone(), + ) { + continue; + } + if identity.kind == crate::MemoryKind::Agent + && (!namespace + .rsplit('/') + .next() + .is_some_and(|part| part.starts_with("agent:")) + || *namespace == spec.root) + { + found.push(access(namespace), MEMORY, at.clone()); + } + if !within(namespace, &spec.root) { + found.push(access(namespace), MEMORY, at.clone()); + } + if let Some(parent) = &identity.fork_parent { + let at = format!("/identities/{i}/fork_parent"); + if found.check(reference("memory", parent, ids), MEMORY, at.clone()) + && let Some(declared) = spec.identities.iter().find(|v| &v.id == parent) + && !namespace.starts_with(&format!("{}/", declared.namespace)) + { + found.push(access(namespace), MEMORY, at); + } + } + if package + .constitution + .read_only_memories + .contains(&identity.id) + && !identity.read_only + { + found.push( + access(namespace), + MEMORY, + format!("/identities/{i}/read_only"), + ); + } + } +} + +/// Each binding's seat, identity, budget and read reaches. +fn bound_seats( + package: &Package, + seats: &BTreeSet<&str>, + ids: &BTreeSet<&str>, + found: &mut Findings, +) { + let spec = &package.memory; + for (i, binding) in spec.bindings.iter().enumerate() { + found.check( + reference("seat", &binding.seat, seats), + MEMORY, + format!("/bindings/{i}/seat"), + ); + found.check( + reference("memory", &binding.identity, ids), + MEMORY, + format!("/bindings/{i}/identity"), + ); + if binding.budget_chars == 0 { + found.push( + Error::Bound { + field: "memory.budget_chars".into(), + }, + MEMORY, + format!("/bindings/{i}/budget_chars"), + ); + } + for (k, reach) in binding.reads.iter().enumerate() { + let at = format!("/bindings/{i}/reads/{k}"); + if reach.inherit { + found.push(access(&reach.at), MEMORY, format!("{at}/inherit")); + continue; + } + let at = format!("{at}/at"); + if found.check( + protected(package, &reach.at, reach.inherit, reach.descendants), + MEMORY, + at.clone(), + ) && !within(&reach.at, &spec.root) + { + found.push(access(&reach.at), MEMORY, at); + } + } + } +} diff --git a/crates/tinyhivemind-lang/src/validation/mod.rs b/crates/tinyhivemind-lang/src/validation/mod.rs index 1a9ea5e4..befd58e4 100644 --- a/crates/tinyhivemind-lang/src/validation/mod.rs +++ b/crates/tinyhivemind-lang/src/validation/mod.rs @@ -1,107 +1,234 @@ //! Referential integrity, bounded policies and namespace access validation. -use crate::{Error, Package, Result}; +//! +//! Every rule pushes its finding, with the file and JSON Pointer it concerns, +//! into one collector. [`validate`] returns the first finding as an +//! [`Error`]; [`diagnose`] returns them all. The rules run in a fixed order, +//! so the first finding is the error a fail-fast check would have stopped +//! at. A check that depends on an earlier one for the same entry, such as +//! the frontmatter of a document that is missing, is skipped rather than +//! reported twice. +mod bounds; +mod memory; +use crate::{ + Diagnostic, Error, Package, Result, + diagnostic::{Findings, HIVE}, +}; use std::collections::{BTreeMap, BTreeSet}; +/// The only `$schema` value this version accepts. +const SCHEMA: &str = "https://tinyhumans.ai/schemas/hive/v1"; + /// Validate references and host-pinned invariants of a complete candidate. /// # Errors -/// Returns a typed failure for any malformed identity, reference or bound. +/// Returns a typed failure for any malformed identity, reference or bound: +/// the first finding [`diagnose`] would report. pub fn validate(package: &Package) -> Result<()> { + let mut found = Findings::default(); + rules(package, &mut found); + found.into_first() +} + +/// Run every validation rule and return every finding, located. +/// +/// An empty result means [`validate`] succeeds; otherwise its first element +/// is the error [`validate`] returns. +#[must_use] +pub fn diagnose(package: &Package) -> Vec { + let mut found = Findings::default(); + rules(package, &mut found); + found.into_diagnostics() +} + +/// Push every validation finding for `package`, in fail-fast order. +pub(crate) fn rules(package: &Package, found: &mut Findings) { let manifest = &package.manifest; - if manifest.version != 1 || manifest.schema != "https://tinyhumans.ai/schemas/hive/v1" { - return Err(Error::Schema); + if manifest.version != 1 { + found.push(Error::Schema, HIVE, "/version".to_owned()); } - identities("hive", [&manifest.id])?; - let roles = identities("role", manifest.roles.iter().map(|v| &v.id))?; - let seats = identities("seat", manifest.seats.iter().map(|v| &v.id))?; - let desks = identities("desk", manifest.desks.iter().map(|v| &v.id))?; - let goals = identities("goal", manifest.goals.iter().map(|v| &v.id))?; - identities("mission", manifest.missions.iter().map(|v| &v.id))?; + if manifest.schema != SCHEMA { + found.push(Error::Schema, HIVE, "/$schema".to_owned()); + } + identities(found, HIVE, "hive", [&manifest.id], |_| "/id".into()); + let roles = identities( + found, + HIVE, + "role", + manifest.roles.iter().map(|v| &v.id), + |i| format!("/roles/{i}/id"), + ); + let seats = identities( + found, + HIVE, + "seat", + manifest.seats.iter().map(|v| &v.id), + |i| format!("/seats/{i}/id"), + ); + let desks = identities( + found, + HIVE, + "desk", + manifest.desks.iter().map(|v| &v.id), + |i| format!("/desks/{i}/id"), + ); + let goals = identities( + found, + HIVE, + "goal", + manifest.goals.iter().map(|v| &v.id), + |i| format!("/goals/{i}/id"), + ); + identities( + found, + HIVE, + "mission", + manifest.missions.iter().map(|v| &v.id), + |i| format!("/missions/{i}/id"), + ); for path in package.documents.keys() { - validate_path(path)?; + found.check(validate_path(path), path, None); } - for role in &manifest.roles { - document_purpose(&role.charter, "roles/")?; - document(package, &role.charter)?; - if let Some(rest) = package.documents[&role.charter].strip_prefix("---\n") { - let Some((metadata, _)) = rest.split_once("\n---\n") else { - return Err(Error::Frontmatter { - seat: role.id.clone(), - }); - }; - let value: serde_json::Value = - serde_json::from_str(metadata).map_err(|_| Error::Frontmatter { - seat: role.id.clone(), - })?; - let canonical = serde_json::json!({"id":role.id,"charter":role.charter}); - if value.as_object().is_none_or(|fields| { - fields - .iter() - .any(|(key, value)| canonical.get(key) != Some(value)) - }) { - return Err(Error::Frontmatter { - seat: role.id.clone(), - }); - } + roster(package, found, &roles, &seats); + plan(package, found, &roles, &goals); + wiring(package, found, &desks); + bounds::bounds(package, found); + memory::memory(package, &seats, found); +} + +/// Roles, seats and desk membership. +fn roster(package: &Package, found: &mut Findings, roles: &BTreeSet<&str>, seats: &BTreeSet<&str>) { + let manifest = &package.manifest; + for (i, role) in manifest.roles.iter().enumerate() { + let at = format!("/roles/{i}/charter"); + if declared_document(package, found, &role.charter, "roles/", &at) { + found.check(charter_frontmatter(package, role), &role.charter, None); } } - for seat in &manifest.seats { - reference("role", &seat.role, &roles)?; - document_purpose(&seat.prompt, "seats/")?; - document(package, &seat.prompt)?; + for (i, seat) in manifest.seats.iter().enumerate() { + found.check( + reference("role", &seat.role, roles), + HIVE, + format!("/seats/{i}/role"), + ); + let at = format!("/seats/{i}/prompt"); + let present = declared_document(package, found, &seat.prompt, "seats/", &at); if seat.template.trim().is_empty() { - return Err(Error::Identity { - kind: "template".into(), - id: seat.template.clone(), - }); + found.push( + Error::Identity { + kind: "template".into(), + id: seat.template.clone(), + }, + HIVE, + format!("/seats/{i}/template"), + ); + } + if present { + found.check(frontmatter(package, seat), &seat.prompt, None); } - frontmatter(package, seat)?; } - for desk in &manifest.desks { - identities("desk member", desk.members.iter())?; - for member in &desk.members { - reference("seat", member, &seats)?; + for (d, desk) in manifest.desks.iter().enumerate() { + identities(found, HIVE, "desk member", desk.members.iter(), |k| { + format!("/desks/{d}/members/{k}") + }); + for (k, member) in desk.members.iter().enumerate() { + found.check( + reference("seat", member, seats), + HIVE, + format!("/desks/{d}/members/{k}"), + ); } } - for goal in &manifest.goals { - for child in &goal.subgoals { - reference("goal", child, &goals)?; +} + +/// Goal decomposition and missions. +fn plan(package: &Package, found: &mut Findings, roles: &BTreeSet<&str>, goals: &BTreeSet<&str>) { + let manifest = &package.manifest; + for (g, goal) in manifest.goals.iter().enumerate() { + for (k, child) in goal.subgoals.iter().enumerate() { + found.check( + reference("goal", child, goals), + HIVE, + format!("/goals/{g}/subgoals/{k}"), + ); } } - validate_goal_graph(package)?; - for mission in &manifest.missions { - reference("role", &mission.role, &roles)?; - for goal in &mission.goals { - reference("goal", goal, &goals)?; + if let Some(cycle) = goal_cycle(package) { + let index = manifest.goals.iter().position(|goal| goal.id == cycle); + found.push( + Error::GoalCycle { id: cycle }, + HIVE, + index.map(|i| format!("/goals/{i}")), + ); + } + for (m, mission) in manifest.missions.iter().enumerate() { + found.check( + reference("role", &mission.role, roles), + HIVE, + format!("/missions/{m}/role"), + ); + for (k, goal) in mission.goals.iter().enumerate() { + found.check( + reference("goal", goal, goals), + HIVE, + format!("/missions/{m}/goals/{k}"), + ); } } - for link in manifest.topology.iter().chain(&manifest.referrals) { - reference("desk", &link.from, &desks)?; - reference("desk", &link.to, &desks)?; +} + +/// Desk links and shared context documents. +fn wiring(package: &Package, found: &mut Findings, desks: &BTreeSet<&str>) { + let manifest = &package.manifest; + for (section, links) in [ + ("topology", &manifest.topology), + ("referrals", &manifest.referrals), + ] { + for (i, link) in links.iter().enumerate() { + for (end, desk) in [("from", &link.from), ("to", &link.to)] { + found.check( + reference("desk", desk, desks), + HIVE, + format!("/{section}/{i}/{end}"), + ); + } + } } - for path in &manifest.context { - document_purpose(path, "context/")?; - document(package, path)?; - crate::patch::validate_context(&package.documents[path]) - .map_err(|_| Error::Context { path: path.clone() })?; + for (i, path) in manifest.context.iter().enumerate() { + let at = format!("/context/{i}"); + if declared_document(package, found, path, "context/", &at) { + found.check( + crate::patch::validate_context(&package.documents[path]) + .map_err(|_| Error::Context { path: path.clone() }), + path, + None, + ); + } } - bounds(package)?; - memory(package, &seats) } + +/// Check identities for blanks and repeats; return the distinct ones seen. fn identities<'a>( + found: &mut Findings, + file: &str, kind: &str, values: impl IntoIterator, -) -> Result> { + at: impl Fn(usize) -> String, +) -> BTreeSet<&'a str> { let mut seen = BTreeSet::new(); - for value in values { + for (i, value) in values.into_iter().enumerate() { if value.trim().is_empty() || !seen.insert(value.as_str()) { - return Err(Error::Identity { - kind: kind.into(), - id: value.clone(), - }); + found.push( + Error::Identity { + kind: kind.into(), + id: value.clone(), + }, + file, + at(i), + ); } } - Ok(seen) + seen } + fn reference(kind: &str, id: &str, known: &BTreeSet<&str>) -> Result<()> { if known.contains(id) { Ok(()) @@ -112,6 +239,7 @@ fn reference(kind: &str, id: &str, known: &BTreeSet<&str>) -> Result<()> { }) } } + /// Check that a document key remains a safe relative package path. /// # Errors /// Returns [`Error::Path`] for absolute, traversal or control-character paths. @@ -128,6 +256,20 @@ pub fn validate_path(path: &str) -> Result<()> { } Ok(()) } + +/// Check a manifest-declared document's directory and presence; report +/// against the manifest field `at` that names it. +fn declared_document( + package: &Package, + found: &mut Findings, + path: &str, + prefix: &str, + at: &str, +) -> bool { + found.check(document_purpose(path, prefix), HIVE, at.to_owned()) + && found.check(document(package, path), HIVE, at.to_owned()) +} + fn document_purpose(path: &str, prefix: &str) -> Result<()> { validate_path(path)?; if !path.starts_with(prefix) @@ -139,15 +281,17 @@ fn document_purpose(path: &str, prefix: &str) -> Result<()> { } Ok(()) } + fn document(package: &Package, path: &str) -> Result<()> { - validate_path(path)?; if package.documents.contains_key(path) { Ok(()) } else { Err(Error::MissingDocument { path: path.into() }) } } -fn validate_goal_graph(package: &Package) -> Result<()> { + +/// The first goal found on a decomposition cycle, if any. +fn goal_cycle(package: &Package) -> Option { let goals: BTreeMap<_, _> = package .manifest .goals @@ -171,7 +315,7 @@ fn validate_goal_graph(package: &Package) -> Result<()> { continue; } if !active.insert(current) { - return Err(Error::GoalCycle { id: current.into() }); + return Some(current.into()); } stack.push((current, true)); if let Some(goal) = goals.get(current) { @@ -181,98 +325,9 @@ fn validate_goal_graph(package: &Package) -> Result<()> { } } } - Ok(()) -} -fn bound(field: &str, valid: bool) -> Result<()> { - if valid { - Ok(()) - } else { - Err(Error::Bound { - field: field.into(), - }) - } -} -fn bounds(package: &Package) -> Result<()> { - let p = &package.manifest.policies; - let c = &package.constitution; - let settings = &package.manifest.coordinator; - bound("constitution", c.max_turns > 0 && c.max_width > 0)?; - bound( - "episode.turn_budget", - p.episode.turn_budget > 0 && u64::from(p.episode.turn_budget) <= c.max_turns, - )?; - bound( - "episode.width", - p.episode.round_width > 0 - && p.episode.round_width <= p.episode.revealed_width - && u64::from(p.episode.revealed_width) - <= u64::try_from(c.max_width).unwrap_or(u64::MAX), - )?; - bound("episode.dominance_cap", p.episode.dominance_cap <= 100)?; - bound( - "division.round_width", - p.division.round_width > 0 - && u64::from(p.division.round_width) <= u64::try_from(c.max_width).unwrap_or(u64::MAX), - )?; - bound( - "routing", - p.routing.round_width > 0 - && p.routing.round_width <= c.max_width - && p.routing.choice_option_limit > 0, - )?; - bound( - "routing.probability", - [ - p.routing.minimum_confidence, - p.routing.high_impact_minimum_confidence, - p.routing.clarification_threshold, - p.routing.high_impact_threshold, - ] - .iter() - .all(|value| value.parts() <= 1_000_000), - )?; - bound( - "coordinator.round_width", - settings.round_width > 0 && settings.round_width <= c.max_width, - )?; - for policy in [&p.conduct, &settings.conduct_policy] { - bound( - "conduct", - policy.child_turn_wall > 0 - && policy.turn_wall > 0 - && policy.child_turn_wall <= policy.turn_wall - && policy.turn_wall <= c.max_turns, - )?; - } - bound("broadcast_budget", settings.broadcast_budget != Some(0))?; - bound( - "digest", - p.digest.input_limit > 0 && p.digest.budget_chars > 0, - )?; - bound( - "aside", - !p.aside.enabled || (p.aside.max_members > 0 && p.aside.max_messages > 0), - )?; - bound( - "quorum", - p.episode.quorum.threshold > 0 && p.episode.quorum.window > 0, - )?; - bound("episode.repetition_cap", p.episode.repetition_cap > 0)?; - bound( - "half_life", - p.episode.weights.half_life > 0 && p.directory.half_life > 0, - )?; - bound("referral", !p.referral.enabled || p.referral.max_hops > 0)?; - bound( - "brevity", - p.brevity.message_chars > 0 && p.brevity.window > 0, - )?; - bound( - "exchange", - !p.exchange.enabled || (p.exchange.contact_cap > 0 && p.exchange.round_cap > 0), - )?; - Ok(()) + None } + /// Check namespace syntax independently of a memory engine. /// # Errors /// Returns [`Error::Namespace`] for malformed namespace paths. @@ -299,154 +354,55 @@ pub fn validate_namespace(namespace: &str) -> Result<()> { } Ok(()) } -fn protected(package: &Package, namespace: &str, inherit: bool, descendants: bool) -> Result<()> { - validate_namespace(namespace)?; - if package.constitution.protected_namespaces.iter().any(|p| { - namespace == p - || namespace - .strip_prefix(p) - .is_some_and(|tail| tail.starts_with('/')) - || (descendants - && p.strip_prefix(namespace) - .is_some_and(|tail| tail.starts_with('/'))) - || (inherit && namespace.starts_with(&format!("{p}/"))) - }) { - return Err(Error::MemoryAccess { - namespace: namespace.into(), - }); - } - - Ok(()) -} -fn memory(package: &Package, seats: &BTreeSet<&str>) -> Result<()> { - let spec = &package.memory; - validate_namespace(&spec.root)?; - let ids = identities("memory", spec.identities.iter().map(|v| &v.id))?; - identities( - "memory namespace", - spec.identities.iter().map(|v| &v.namespace), - )?; - identities("memory binding", spec.bindings.iter().map(|v| &v.seat))?; - for namespace in &package.constitution.protected_namespaces { - validate_namespace(namespace)?; - } - for id in &package.constitution.read_only_memories { - reference("memory", id, &ids)?; - } - for identity in &spec.identities { - protected(package, &identity.namespace, false, false)?; - if identity.kind == crate::MemoryKind::Agent - && (!identity - .namespace - .rsplit('/') - .next() - .is_some_and(|part| part.starts_with("agent:")) - || identity.namespace == spec.root) - { - return Err(Error::MemoryAccess { - namespace: identity.namespace.clone(), - }); - } - if identity.namespace != spec.root - && !identity.namespace.starts_with(&format!("{}/", spec.root)) - { - return Err(Error::MemoryAccess { - namespace: identity.namespace.clone(), - }); - } - if let Some(parent) = &identity.fork_parent { - reference("memory", parent, &ids)?; - let parent_namespace = &spec - .identities +/// A role charter's frontmatter may restate only its `id` and `charter`. +fn charter_frontmatter(package: &Package, role: &crate::Role) -> Result<()> { + let malformed = || Error::Frontmatter { + seat: role.id.clone(), + }; + if let Some(rest) = package.documents[&role.charter].strip_prefix("---\n") { + let (metadata, _) = rest.split_once("\n---\n").ok_or_else(malformed)?; + let value: serde_json::Value = serde_json::from_str(metadata).map_err(|_| malformed())?; + let canonical = serde_json::json!({"id":role.id,"charter":role.charter}); + if value.as_object().is_none_or(|fields| { + fields .iter() - .find(|v| &v.id == parent) - .ok_or_else(|| Error::Reference { - kind: "memory".into(), - id: parent.clone(), - })? - .namespace; - if !identity - .namespace - .starts_with(&format!("{parent_namespace}/")) - { - return Err(Error::MemoryAccess { - namespace: identity.namespace.clone(), - }); - } - } - if package - .constitution - .read_only_memories - .contains(&identity.id) - && !identity.read_only - { - return Err(Error::MemoryAccess { - namespace: identity.namespace.clone(), - }); - } - } - for binding in &spec.bindings { - reference("seat", &binding.seat, seats)?; - reference("memory", &binding.identity, &ids)?; - bound("memory.budget_chars", binding.budget_chars > 0)?; - for reach in &binding.reads { - if reach.inherit { - return Err(Error::MemoryAccess { - namespace: reach.at.clone(), - }); - } - protected(package, &reach.at, reach.inherit, reach.descendants)?; - if reach.at != spec.root && !reach.at.starts_with(&format!("{}/", spec.root)) { - return Err(Error::MemoryAccess { - namespace: reach.at.clone(), - }); - } + .any(|(key, value)| canonical.get(key) != Some(value)) + }) { + return Err(malformed()); } } Ok(()) } + +/// A seat prompt's frontmatter must agree with the seat and its binding. fn frontmatter(package: &Package, seat: &crate::Seat) -> Result<()> { + let malformed = || Error::Frontmatter { + seat: seat.id.clone(), + }; let source = &package.documents[&seat.prompt]; if let Some(rest) = source.strip_prefix("---\n") { - let Some((metadata, _)) = rest.split_once("\n---\n") else { - return Err(Error::Frontmatter { - seat: seat.id.clone(), - }); - }; - let value: serde_json::Value = - serde_json::from_str(metadata).map_err(|_| Error::Frontmatter { - seat: seat.id.clone(), - })?; - let mut canonical = serde_json::to_value(seat).map_err(|_| Error::Frontmatter { - seat: seat.id.clone(), - })?; + let (metadata, _) = rest.split_once("\n---\n").ok_or_else(malformed)?; + let value: serde_json::Value = serde_json::from_str(metadata).map_err(|_| malformed())?; + let mut canonical = serde_json::to_value(seat).map_err(|_| malformed())?; if let Some(binding) = package .memory .bindings .iter() .find(|binding| binding.seat == seat.id) { - let settings = serde_json::to_value(binding).map_err(|_| Error::Frontmatter { - seat: seat.id.clone(), - })?; + let settings = serde_json::to_value(binding).map_err(|_| malformed())?; for key in ["reads", "recall_at", "budget_chars", "remember"] { canonical[key] = settings[key].clone(); } canonical["memory"] = serde_json::Value::String(binding.identity.clone()); } - let Some(fields) = value.as_object() else { - return Err(Error::Frontmatter { - seat: seat.id.clone(), - }); - }; + let fields = value.as_object().ok_or_else(malformed)?; if fields .iter() .any(|(key, value)| canonical.get(key) != Some(value)) { - return Err(Error::Frontmatter { - seat: seat.id.clone(), - }); + return Err(malformed()); } } Ok(()) diff --git a/crates/tinyhivemind-lang/src/validation/test/README.md b/crates/tinyhivemind-lang/src/validation/test/README.md new file mode 100644 index 00000000..a3539d5c --- /dev/null +++ b/crates/tinyhivemind-lang/src/validation/test/README.md @@ -0,0 +1,7 @@ +# validation tests + +| File | Purpose | +| --- | --- | +| `mod.rs` | Module doc and submodule wiring | +| `rules.rs` | Wire contracts and fail-fast validation and parsing regressions | +| `locations.rs` | Where `diagnose` locates each finding, and agreement with `validate` | diff --git a/crates/tinyhivemind-lang/src/validation/test/locations.rs b/crates/tinyhivemind-lang/src/validation/test/locations.rs new file mode 100644 index 00000000..ab42cbb3 --- /dev/null +++ b/crates/tinyhivemind-lang/src/validation/test/locations.rs @@ -0,0 +1,382 @@ +//! Where each validation finding is located, and agreement between +//! `diagnose` and fail-fast `validate`. +#![allow(clippy::expect_used)] // Tests fail loudly on unexpected results. +use crate::*; +use serde_json::json; + +/// A valid package exercising roles, seats, desks, goals, missions, links, +/// context and memory. +fn fixture() -> Package { + let mut p = Package::default(); + p.manifest.roles.push(Role { + id: "builder".into(), + charter: "roles/builder.md".into(), + }); + p.manifest.seats.push(Seat { + id: "alice".into(), + label: "Alice".into(), + role: "builder".into(), + prompt: "seats/alice.md".into(), + template: "agent".into(), + config: json!({}), + ..Seat::default() + }); + p.manifest.desks.push( + serde_json::from_value(json!({"id":"main","name":"Main","description":null, + "members":["alice"],"responder_mode":"auto"})) + .expect("a desk literal decodes"), + ); + p.manifest.goals.push(Goal { + id: "g".into(), + ..Goal::default() + }); + p.manifest.missions.push(Mission { + id: "m".into(), + role: "builder".into(), + goals: vec!["g".into()], + }); + p.manifest.topology.push(Link { + from: "main".into(), + to: "main".into(), + }); + p.manifest.referrals.push(Link { + from: "main".into(), + to: "main".into(), + }); + p.manifest.context.push("context/notes.md".into()); + p.documents + .insert("roles/builder.md".into(), "Build.\n".into()); + p.documents + .insert("seats/alice.md".into(), "Solve.\n".into()); + p.documents + .insert("context/notes.md".into(), "\nA\n".into()); + p.memory.identities.push(MemoryIdentity { + id: "notes".into(), + namespace: "team:hive/agent:alice".into(), + kind: MemoryKind::Agent, + lifecycle: MemoryLifecycle::Run, + read_only: false, + fork_parent: None, + }); + p.memory.bindings.push(SeatMemory { + seat: "alice".into(), + identity: "notes".into(), + reads: vec![MemoryReach { + at: "team:hive/agent:alice".into(), + inherit: false, + descendants: false, + }], + recall_at: vec![], + budget_chars: 10, + remember: vec![], + }); + p +} + +/// Diagnose `fixture` after `edit`; check `validate` returns the first. +fn located(edit: impl FnOnce(&mut Package)) -> Vec<(String, Option, Rule)> { + let mut p = fixture(); + edit(&mut p); + let diagnostics = diagnose(&p); + let first = validate(&p).err().map(|error| error.to_string()); + assert_eq!(first, diagnostics.first().map(|d| d.message.clone())); + diagnostics + .into_iter() + .map(|d| (d.file, d.pointer, d.rule)) + .collect() +} + +fn at(file: &str, pointer: &str, rule: Rule) -> (String, Option, Rule) { + (file.into(), Some(pointer.into()), rule) +} + +fn whole(file: &str, rule: Rule) -> (String, Option, Rule) { + (file.into(), None, rule) +} + +#[test] +fn the_fixture_is_valid() { + assert_eq!(located(|_| {}), []); +} + +#[test] +fn manifest_identity_and_schema_findings_name_their_fields() { + assert_eq!( + located(|p| { + p.manifest.version = 2; + p.manifest.schema = "other".into(); + p.manifest.id = " ".into(); + p.manifest.roles.push(p.manifest.roles[0].clone()); + p.manifest.missions.push(p.manifest.missions[0].clone()); + p.manifest.goals.push(p.manifest.goals[0].clone()); + p.manifest.desks[0].members.push("alice".into()); + }), + [ + at("hive.json", "/version", Rule::Schema), + at("hive.json", "/$schema", Rule::Schema), + at("hive.json", "/id", Rule::Identity), + at("hive.json", "/roles/1/id", Rule::Identity), + at("hive.json", "/goals/1/id", Rule::Identity), + at("hive.json", "/missions/1/id", Rule::Identity), + at("hive.json", "/desks/0/members/1", Rule::Identity), + ] + ); +} + +#[test] +fn declared_documents_are_reported_at_the_manifest_field_naming_them() { + assert_eq!( + located(|p| { + p.manifest.roles[0].charter = "seats/builder.md".into(); + p.manifest.seats[0].prompt = "seats/missing.md".into(); + p.manifest.seats[0].template = String::new(); + p.manifest.context[0] = "context/gone.md".into(); + p.documents.insert("a/../b.md".into(), String::new()); + }), + [ + whole("a/../b.md", Rule::Path), + at("hive.json", "/roles/0/charter", Rule::Path), + at("hive.json", "/seats/0/prompt", Rule::MissingDocument), + at("hive.json", "/seats/0/template", Rule::Identity), + at("hive.json", "/context/0", Rule::MissingDocument), + ] + ); +} + +#[test] +fn frontmatter_and_markers_are_reported_on_their_markdown_files() { + assert_eq!( + located(|p| { + p.documents.insert( + "roles/builder.md".into(), + "---\n{\"id\":\"x\"}\n---\nB\n".into(), + ); + p.documents + .insert("seats/alice.md".into(), "---\n[]\n---\nS\n".into()); + p.documents.insert( + "context/notes.md".into(), + "\n\n".into(), + ); + }), + [ + whole("roles/builder.md", Rule::Frontmatter), + whole("seats/alice.md", Rule::Frontmatter), + whole("context/notes.md", Rule::Context), + ] + ); + assert_eq!( + located(|p| { + p.documents + .insert("roles/builder.md".into(), "---\nunclosed".into()); + }), + [whole("roles/builder.md", Rule::Frontmatter)] + ); +} + +#[test] +fn references_name_the_field_that_fails_to_resolve() { + assert_eq!( + located(|p| { + p.manifest.seats[0].role = "ghost".into(); + p.manifest.desks[0].members[0] = "ghost".into(); + p.manifest.goals[0].subgoals.push("ghost".into()); + p.manifest.missions[0].role = "ghost".into(); + p.manifest.missions[0].goals[0] = "ghost".into(); + p.manifest.topology[0].to = "ghost".into(); + p.manifest.referrals[0].from = "ghost".into(); + }), + [ + at("hive.json", "/seats/0/role", Rule::Reference), + at("hive.json", "/desks/0/members/0", Rule::Reference), + at("hive.json", "/goals/0/subgoals/0", Rule::Reference), + at("hive.json", "/missions/0/role", Rule::Reference), + at("hive.json", "/missions/0/goals/0", Rule::Reference), + at("hive.json", "/topology/0/to", Rule::Reference), + at("hive.json", "/referrals/0/from", Rule::Reference), + ] + ); +} + +#[test] +fn a_goal_cycle_names_a_goal_on_it() { + assert_eq!( + located(|p| p.manifest.goals[0].subgoals.push("g".into())), + [at("hive.json", "/goals/0", Rule::GoalCycle)] + ); +} + +#[test] +fn bounds_name_the_policy_field_or_the_constitution_cap() { + assert_eq!( + located(|p| p.constitution.max_turns = 0), + [ + at("constitution.json", "/max_turns", Rule::Bound), + at("hive.json", "/policies/episode/turn_budget", Rule::Bound), + at("hive.json", "/policies/conduct", Rule::Bound), + at("hive.json", "/coordinator/conduct_policy", Rule::Bound), + ] + ); + assert_eq!( + located(|p| p.constitution.max_width = 0)[0], + at("constitution.json", "/max_width", Rule::Bound) + ); + assert_eq!( + located(|p| { + let e = &mut p.manifest.policies.episode; + e.round_width = 0; + e.dominance_cap = 101; + e.quorum.window = 0; + e.repetition_cap = 0; + e.weights.half_life = 0; + let q = &mut p.manifest.policies; + q.division.round_width = 0; + q.routing.choice_option_limit = 0; + q.digest.input_limit = 0; + q.aside.enabled = true; + q.referral.enabled = true; + q.brevity.window = 0; + q.exchange.enabled = true; + p.manifest.coordinator.round_width = 0; + p.manifest.coordinator.broadcast_budget = Some(0); + }), + [ + at("hive.json", "/policies/episode/round_width", Rule::Bound), + at("hive.json", "/policies/episode/dominance_cap", Rule::Bound), + at("hive.json", "/policies/division/round_width", Rule::Bound), + at("hive.json", "/policies/routing", Rule::Bound), + at("hive.json", "/coordinator/round_width", Rule::Bound), + at("hive.json", "/coordinator/broadcast_budget", Rule::Bound), + at("hive.json", "/policies/digest", Rule::Bound), + at("hive.json", "/policies/aside", Rule::Bound), + at("hive.json", "/policies/episode/quorum", Rule::Bound), + at("hive.json", "/policies/episode/repetition_cap", Rule::Bound), + at( + "hive.json", + "/policies/episode/weights/half_life", + Rule::Bound + ), + at("hive.json", "/policies/referral", Rule::Bound), + at("hive.json", "/policies/brevity", Rule::Bound), + at("hive.json", "/policies/exchange", Rule::Bound), + ] + ); + assert_eq!( + located(|p| { + p.manifest.policies.episode.revealed_width = 9; + p.manifest.policies.episode.round_width = 9; + p.manifest.policies.directory.half_life = 0; + }), + [ + at("hive.json", "/policies/episode/revealed_width", Rule::Bound), + at("hive.json", "/policies/directory/half_life", Rule::Bound), + ] + ); +} + +#[test] +fn memory_findings_name_memory_json_or_the_constitution() { + assert_eq!( + located(|p| { + p.memory.root = "bad".into(); + p.memory.identities.push(p.memory.identities[0].clone()); + p.memory.bindings.push(p.memory.bindings[0].clone()); + p.constitution.protected_namespaces = vec!["nope".into()]; + p.constitution.read_only_memories = vec!["ghost".into()]; + }), + [ + at("memory.json", "/root", Rule::Namespace), + at("memory.json", "/identities/1/id", Rule::Identity), + at("memory.json", "/identities/1/namespace", Rule::Identity), + at("memory.json", "/bindings/1/seat", Rule::Identity), + at( + "constitution.json", + "/protected_namespaces/0", + Rule::Namespace + ), + at( + "constitution.json", + "/read_only_memories/0", + Rule::Reference + ), + at("memory.json", "/identities/0/namespace", Rule::MemoryAccess), + at("memory.json", "/identities/1/namespace", Rule::MemoryAccess), + at("memory.json", "/bindings/0/reads/0/at", Rule::MemoryAccess), + at("memory.json", "/bindings/1/reads/0/at", Rule::MemoryAccess), + ] + ); +} + +#[test] +fn memory_identity_and_reach_findings_name_the_offending_field() { + assert_eq!( + located(|p| { + let notes = p.memory.identities[0].clone(); + p.memory.identities[0].namespace = "team:hive/project:p".into(); + p.memory.identities.push(MemoryIdentity { + id: "fork".into(), + namespace: "team:hive/agent:fork".into(), + fork_parent: Some("notes".into()), + ..notes.clone() + }); + p.memory.identities.push(MemoryIdentity { + id: "orphan".into(), + namespace: "team:hive/agent:orphan".into(), + fork_parent: Some("ghost".into()), + ..notes.clone() + }); + p.memory.identities.push(MemoryIdentity { + id: "outside".into(), + namespace: "team:else/agent:x".into(), + ..notes.clone() + }); + p.memory.identities.push(MemoryIdentity { + id: "held".into(), + namespace: "team:hive/project:held".into(), + kind: MemoryKind::Pool, + ..notes + }); + p.constitution.read_only_memories = vec!["held".into()]; + p.constitution.protected_namespaces = vec!["team:hive/project:secret".into()]; + let binding = &mut p.memory.bindings[0]; + binding.identity = "ghost".into(); + binding.budget_chars = 0; + binding.reads = vec![ + MemoryReach { + at: "team:hive".into(), + inherit: true, + descendants: false, + }, + MemoryReach { + at: "team:hive".into(), + inherit: false, + descendants: true, + }, + MemoryReach { + at: "team:else".into(), + inherit: false, + descendants: false, + }, + ]; + }), + [ + at("memory.json", "/identities/0/namespace", Rule::MemoryAccess), + at( + "memory.json", + "/identities/1/fork_parent", + Rule::MemoryAccess + ), + at("memory.json", "/identities/2/fork_parent", Rule::Reference), + at("memory.json", "/identities/3/namespace", Rule::MemoryAccess), + at("memory.json", "/identities/4/read_only", Rule::MemoryAccess), + at("memory.json", "/bindings/0/identity", Rule::Reference), + at("memory.json", "/bindings/0/budget_chars", Rule::Bound), + at( + "memory.json", + "/bindings/0/reads/0/inherit", + Rule::MemoryAccess + ), + at("memory.json", "/bindings/0/reads/1/at", Rule::MemoryAccess), + at("memory.json", "/bindings/0/reads/2/at", Rule::MemoryAccess), + ] + ); +} diff --git a/crates/tinyhivemind-lang/src/validation/test/mod.rs b/crates/tinyhivemind-lang/src/validation/test/mod.rs new file mode 100644 index 00000000..ed1f132b --- /dev/null +++ b/crates/tinyhivemind-lang/src/validation/test/mod.rs @@ -0,0 +1,4 @@ +//! Package validation: the fail-fast regressions, and every finding's +//! location when the rules collect instead of stopping. +mod locations; +mod rules; diff --git a/crates/tinyhivemind-lang/src/validation/test.rs b/crates/tinyhivemind-lang/src/validation/test/rules.rs similarity index 100% rename from crates/tinyhivemind-lang/src/validation/test.rs rename to crates/tinyhivemind-lang/src/validation/test/rules.rs diff --git a/crates/tinyhivemind-lang/tests/README.md b/crates/tinyhivemind-lang/tests/README.md index 26929fcc..5b9da36a 100644 --- a/crates/tinyhivemind-lang/tests/README.md +++ b/crates/tinyhivemind-lang/tests/README.md @@ -5,3 +5,6 @@ This directory owns tests for the portable hive language. | File | Purpose | | --- | --- | | example_packages.rs | Behavior regression tests. | +| gallery_packages.rs | Every package under `examples/hives/` parses and lowers, with its desks, seats, links, memory bindings and constitution pinned; no seat reaches the web except `web_search` on `euler-hive`'s research desk. | +| direct_edits.rs | Direct edits to the gallery's `self-improving` package checked by `check`: accepted, refused with located diagnostics, and reproduced through derived patches. | +| euler_hive_package.rs | The `euler-hive` package parses and lowers from disk: five linked desks with referral enabled, web search only on research seats, `run_python` only on compute and verify, distinct personas, an agent memory binding per seat that reads the verified pool, and a placeholder problem. | diff --git a/crates/tinyhivemind-lang/tests/direct_edits.rs b/crates/tinyhivemind-lang/tests/direct_edits.rs new file mode 100644 index 00000000..a187a23b --- /dev/null +++ b/crates/tinyhivemind-lang/tests/direct_edits.rs @@ -0,0 +1,303 @@ +//! Direct edits to the gallery's `self-improving` package, checked the way an +//! orchestrator's edits would be: whole files in, diagnostics or a review out. +//! +//! The package is read with `include_str!`, so this suite follows the +//! gallery rather than a copy of it. +use std::collections::BTreeMap; +use tinyhivemind_lang::{ + Diagnostic, Review, Rule, check, check_packages, diagnose, diagnose_documents, + lineage::{self, Direction, ObjectiveScore, Verdict, VersionMetadata}, + parse, + patch::{self, PatchClass}, + validate, +}; + +type Result = std::result::Result>; + +macro_rules! package { + ($($path:literal),* $(,)?) => { + BTreeMap::from([$(( + $path.to_owned(), + include_str!(concat!("../examples/hives/self-improving/", $path)).to_owned(), + )),*]) + }; +} + +fn original() -> BTreeMap { + package![ + "hive.json", + "constitution.json", + "memory.json", + "README.md", + "context/playbook.md", + "roles/README.md", + "roles/critic.md", + "roles/editor.md", + "roles/solver.md", + "seats/README.md", + "seats/critic.md", + "seats/editor.md", + "seats/solver.md", + ] +} + +/// The original with `path` rewritten by `edit`. +fn edited(path: &str, edit: impl FnOnce(&str) -> String) -> BTreeMap { + let mut files = original(); + let next = edit(&files[path]); + files.insert(path.to_owned(), next); + files +} + +/// Rewrite one JSON file through its decoded value. +fn edit_json( + files: &mut BTreeMap, + path: &str, + edit: impl FnOnce(&mut serde_json::Value), +) -> Result<()> { + let mut value: serde_json::Value = serde_json::from_str(&files[path])?; + edit(&mut value); + files.insert(path.to_owned(), serde_json::to_string_pretty(&value)?); + Ok(()) +} + +type Checked = std::result::Result>; + +fn refusal(result: Checked) -> Result> { + result + .err() + .ok_or_else(|| "expected the edit to be refused".into()) +} + +/// The review of an accepted edit, or its diagnostics as the test failure. +fn accepted(result: Checked) -> Result { + result.map_err(|diagnostics| { + let lines: Vec<_> = diagnostics.iter().map(ToString::to_string).collect(); + lines.join("\n").into() + }) +} + +fn located(diagnostics: &[Diagnostic]) -> Vec<(&str, Option<&str>, Rule)> { + diagnostics + .iter() + .map(|d| (d.file.as_str(), d.pointer.as_deref(), d.rule)) + .collect() +} + +#[test] +fn a_prompt_edit_is_accepted_as_one_prompt_change() -> Result<()> { + let files = edited("seats/solver.md", |body| { + format!("{body}Include canceled invoices.\n") + }); + let review = accepted(check(&original(), &files))?; + assert_eq!(review.changes.len(), 1); + assert_eq!(review.changes[0].class, PatchClass::Prompt); + // The recorded form is exactly what `diff` derives for the class. + let base = parse(&original())?; + assert_eq!( + review.changes[0], + patch::diff(&base, &review.candidate, PatchClass::Prompt)? + ); + assert_eq!(review.candidate, parse(&files)?); + Ok(()) +} + +#[test] +fn a_policy_edit_is_refused_at_the_field_it_changed() -> Result<()> { + let mut files = original(); + edit_json(&mut files, "hive.json", |hive| { + hive["policies"]["episode"]["turn_budget"] = 12.into(); + })?; + let diagnostics = refusal(check(&original(), &files))?; + assert_eq!( + located(&diagnostics), + [( + "hive.json", + Some("/policies/episode/turn_budget"), + Rule::ForbiddenClass + )] + ); + assert_eq!( + diagnostics[0].to_string(), + "hive.json:/policies/episode/turn_budget: forbidden_class: \ + the constitution does not permit policy edits" + ); + Ok(()) +} + +#[test] +fn every_violation_of_one_edit_is_reported_together() -> Result<()> { + let mut files = edited("context/playbook.md", |playbook| { + playbook.replace("round only", "HELDOUT-7F3A round only") + }); + edit_json(&mut files, "constitution.json", |constitution| { + constitution["max_turns"] = 120.into(); + })?; + edit_json(&mut files, "hive.json", |hive| { + hive["missions"][0]["role"] = "ghost".into(); + })?; + let diagnostics = refusal(check(&original(), &files))?; + assert_eq!( + located(&diagnostics), + [ + ("constitution.json", Some("/max_turns"), Rule::Immutable), + ("context/playbook.md", None, Rule::Contamination), + ("hive.json", Some("/missions/0/role"), Rule::Reference), + ] + ); + Ok(()) +} + +#[test] +fn a_multi_class_edit_reproduces_through_its_derived_patches() -> Result<()> { + let mut files = edited("seats/critic.md", |body| { + format!("{body}Name the invoice id.\n") + }); + files.insert( + "context/playbook.md".into(), + files["context/playbook.md"].replace("integer cents", "whole cents"), + ); + edit_json(&mut files, "hive.json", |hive| { + if let Some(criteria) = hive["goals"][0]["acceptance"].as_array_mut() { + criteria.push("Shows every line item".into()); + } + })?; + let review = accepted(check(&original(), &files))?; + let classes: Vec<_> = review.changes.iter().map(|change| change.class).collect(); + assert_eq!( + classes, + [PatchClass::Prompt, PatchClass::Context, PatchClass::Goal] + ); + // One lineage record per change, chained, ends at the edited package. + let mut current = parse(&original())?; + let mut parent = None; + let mut ids = Vec::new(); + for change in &review.changes { + current = patch::apply(¤t, change)?.candidate; + let watermarks = BTreeMap::from([("solver-notes".into(), 0), ("eval-ledger".into(), 0)]); + let record = lineage::record( + ¤t, + VersionMetadata { + parent: parent.clone(), + patch: change.clone(), + rationale: "direct edit".into(), + transcript_refs: vec![], + scores: BTreeMap::from([( + "heldout".into(), + ObjectiveScore { + value: 3.0, + direction: Direction::Maximize, + }, + )]), + verdict: Verdict::Accepted, + memory_watermarks: watermarks.clone(), + }, + )?; + parent = Some(record.id.clone()); + ids.push(record.id); + } + assert_eq!(current, review.candidate); + assert_eq!(current, parse(&files)?); + let watermarks = BTreeMap::from([("solver-notes".into(), 0), ("eval-ledger".into(), 0)]); + assert_eq!( + ids.last(), + Some(&lineage::content_id(¤t, &watermarks)?) + ); + assert_eq!(ids.len(), 3); + Ok(()) +} + +#[test] +fn unparseable_json_names_its_file_and_line() -> Result<()> { + let files = edited("memory.json", |memory| { + memory.replacen("\"root\"", "root", 1) + }); + let diagnostics = refusal(check(&original(), &files))?; + assert_eq!(located(&diagnostics), [("memory.json", None, Rule::Json)]); + assert!( + diagnostics[0].message.contains("line 2 column 3"), + "{}", + diagnostics[0] + ); + Ok(()) +} + +#[test] +fn an_unowned_change_is_reported_rather_than_accepted() -> Result<()> { + let mut files = edited("README.md", |readme| format!("{readme}\nEdited.\n")); + edit_json(&mut files, "hive.json", |hive| { + hive["name"] = "Renamed".into(); + })?; + files.insert("notes.txt".into(), "scratch\n".into()); + let diagnostics = refusal(check(&original(), &files))?; + assert_eq!( + located(&diagnostics), + [ + ("README.md", None, Rule::Unowned), + ("hive.json", Some("/name"), Rule::Unowned), + ("notes.txt", None, Rule::Unowned), + ] + ); + Ok(()) +} + +#[test] +fn diagnose_collects_several_validation_errors_at_once() -> Result<()> { + let mut package = parse(&original())?; + package.manifest.seats[0].role = "ghost".into(); + package.manifest.desks[0].members.push("phantom".into()); + package.manifest.policies.episode.turn_budget = 0; + let diagnostics = diagnose(&package); + assert_eq!( + located(&diagnostics), + [ + ("hive.json", Some("/seats/0/role"), Rule::Reference), + ("hive.json", Some("/desks/0/members/3"), Rule::Reference), + ( + "hive.json", + Some("/policies/episode/turn_budget"), + Rule::Bound + ), + ] + ); + // `validate` still stops at, and returns, the first of them. + let first = validate(&package) + .err() + .ok_or("expected a validation error")?; + assert_eq!(first.to_string(), diagnostics[0].message); + Ok(()) +} + +#[test] +fn diagnose_documents_reports_every_canonical_file_that_fails() { + let mut files = original(); + files.insert("hive.json".into(), "{".into()); + files.remove("memory.json"); + let (package, diagnostics) = diagnose_documents(&files); + assert!(package.is_none()); + assert_eq!( + located(&diagnostics), + [ + ("hive.json", None, Rule::Json), + ("memory.json", None, Rule::MissingDocument), + ] + ); +} + +#[test] +fn check_packages_matches_check_on_the_decoded_files() -> Result<()> { + let base = parse(&original())?; + let mut edit = base.clone(); + edit.manifest.policies.episode.turn_budget = 12; + let diagnostics = refusal(check_packages(&base, &edit))?; + assert_eq!( + located(&diagnostics), + [( + "hive.json", + Some("/policies/episode/turn_budget"), + Rule::ForbiddenClass + )] + ); + assert_eq!((accepted(check_packages(&base, &base))?.changes).len(), 0); + Ok(()) +} diff --git a/crates/tinyhivemind-lang/tests/euler_hive_package.rs b/crates/tinyhivemind-lang/tests/euler_hive_package.rs new file mode 100644 index 00000000..f19a5155 --- /dev/null +++ b/crates/tinyhivemind-lang/tests/euler_hive_package.rs @@ -0,0 +1,249 @@ +//! The `euler-hive` gallery package parses, lowers, and keeps the tool and +//! memory gating a live host relies on. +//! +//! The package is read from disk, the way a host loads it, so a file added to +//! the directory is part of what is validated. +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use tinyhivemind_lang::{ + EntryKind, LoweredHive, MemoryKind, MemoryLifecycle, Package, RecallMoment, lower, parse, +}; + +type Result = std::result::Result>; + +const ROOT: &str = "team:euler-hive"; +/// Tool names that reach the web. +const WEB_TOOLS: [&str; 4] = ["web_search", "web_fetch", "browser", "http"]; +/// The hive's own speaking and memory tools, which every seat holds. +const HIVE_TOOLS: [&str; 5] = [ + "hive_broadcast", + "hive_refer", + "hive_complete", + "hive_remember", + "hive_recall", +]; +/// Harness tools no seat may hold: the sandbox is the only place code runs. +const HOST_TOOLS: [&str; 5] = ["shell", "file_read", "file_write", "file_edit", "web_fetch"]; + +fn package_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/hives/euler-hive") +} + +fn walk(root: &Path, dir: &Path, into: &mut BTreeMap) -> Result<()> { + let mut entries: Vec = std::fs::read_dir(dir)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>()?; + entries.sort(); + for path in entries { + if path.is_dir() { + walk(root, &path, into)?; + continue; + } + let relative = path.strip_prefix(root)?; + let key: Vec = relative + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect(); + into.insert(key.join("/"), std::fs::read_to_string(&path)?); + } + Ok(()) +} + +fn euler_hive() -> Result<(Package, LoweredHive)> { + let mut documents = BTreeMap::new(); + let dir = package_dir(); + walk(&dir, &dir, &mut documents)?; + let package = parse(&documents)?; + let lowered = lower(&package)?; + Ok((package, lowered)) +} + +fn desk_of<'a>(hive: &'a LoweredHive, seat: &str) -> &'a str { + hive.desks + .iter() + .find(|desk| desk.members.iter().any(|member| member == seat)) + .map_or("", |desk| desk.id.as_str()) +} + +#[test] +fn euler_hive_is_five_linked_desks_with_referral_enabled() -> Result<()> { + let (package, hive) = euler_hive()?; + let desks: Vec<_> = hive.desks.iter().map(|desk| desk.id.as_str()).collect(); + assert_eq!( + desks, + ["research", "theory", "compute", "verify", "synthesis"] + ); + assert!( + (12..=20).contains(&hive.seats.len()), + "{}", + hive.seats.len() + ); + let chain: Vec<_> = hive + .topology + .iter() + .map(|link| (link.from.as_str(), link.to.as_str())) + .collect(); + assert_eq!( + chain, + [ + ("research", "theory"), + ("theory", "compute"), + ("compute", "verify"), + ("verify", "synthesis") + ] + ); + let referrals: BTreeSet<_> = hive + .referrals + .iter() + .map(|link| (link.from.as_str(), link.to.as_str())) + .collect(); + for link in &chain { + assert!(referrals.contains(link), "{link:?} is not referable"); + } + for back in [("verify", "compute"), ("synthesis", "verify")] { + assert!(referrals.contains(&back), "{back:?} has no way back"); + } + let referral = hive.policies.referral; + assert!(referral.enabled && referral.returns); + assert!(referral.max_hops >= 4, "the pipeline is four hops deep"); + assert!(u64::from(hive.policies.episode.turn_budget) <= package.constitution.max_turns); + assert_eq!(hive.context.len(), 1); + Ok(()) +} + +#[test] +fn only_research_seats_reach_the_web_and_only_compute_and_verify_run_code() -> Result<()> { + let (_, hive) = euler_hive()?; + for seat in &hive.seats { + let desk = desk_of(&hive, &seat.id); + let allowed: BTreeSet<_> = seat.tools.allow.iter().map(String::as_str).collect(); + let mut expected: BTreeSet<_> = HIVE_TOOLS.into_iter().collect(); + match desk { + "research" => { + expected.insert("web_search"); + } + "compute" | "verify" => { + expected.insert("run_python"); + } + "theory" | "synthesis" => {} + other => return Err(format!("{} sits on unexpected desk {other:?}", seat.id).into()), + } + assert_eq!(allowed, expected, "{} allows the wrong tools", seat.id); + for tool in HOST_TOOLS { + assert!( + seat.tools.deny.iter().any(|denied| denied == tool), + "{} does not deny {tool}", + seat.id + ); + } + if desk != "research" { + for tool in WEB_TOOLS { + assert!(!allowed.contains(tool), "{} allows {tool}", seat.id); + assert!( + seat.tools.deny.iter().any(|denied| denied == tool), + "{} does not deny {tool}", + seat.id + ); + } + } + if !matches!(desk, "compute" | "verify") { + assert!( + seat.tools.deny.iter().any(|denied| denied == "run_python"), + "{} does not deny run_python", + seat.id + ); + } + } + Ok(()) +} + +/// The persona a seat prompt opens with: `You are a "".` +fn persona(prompt: &str) -> Option<&str> { + prompt + .lines() + .next()? + .strip_prefix("You are a \"")? + .strip_suffix("\".") + .filter(|persona| !persona.trim().is_empty()) +} + +#[test] +fn every_euler_seat_opens_with_a_distinct_persona() -> Result<()> { + let (_, hive) = euler_hive()?; + let mut seen = BTreeSet::new(); + for seat in &hive.seats { + let persona = persona(&seat.prompt).ok_or(format!("{} has no persona line", seat.id))?; + assert!( + seen.insert(persona.to_lowercase()), + "{} repeats {persona:?}", + seat.id + ); + assert!(seat.prompt.lines().count() >= 3, "{} is too thin", seat.id); + } + Ok(()) +} + +#[test] +fn every_seat_binds_its_own_agent_memory_and_reads_the_verified_pool() -> Result<()> { + let (package, hive) = euler_hive()?; + let pool = format!("{ROOT}/project:verified"); + assert!( + package + .memory + .identities + .iter() + .any(|identity| identity.namespace == pool && identity.kind == MemoryKind::Pool) + ); + for seat in &hive.seats { + let binding = seat + .memory + .as_ref() + .ok_or(format!("{} has no memory", seat.id))?; + assert_eq!(binding.kind, MemoryKind::Agent); + assert_eq!(binding.root, ROOT); + assert_eq!(binding.agent_id, seat.id); + assert_eq!(binding.namespace, format!("{ROOT}/agent:{}", seat.id)); + assert_eq!(binding.identity.lifecycle, MemoryLifecycle::Run); + assert!(!binding.identity.read_only); + assert_eq!( + binding.settings.recall_at, + [ + RecallMoment::SessionStart, + RecallMoment::Rejoin, + RecallMoment::Compaction + ] + ); + for kind in [ + EntryKind::Observation, + EntryKind::FailedAttempt, + EntryKind::Outcome, + ] { + assert!(binding.settings.remember.contains(&kind), "{}", seat.id); + } + let reads: Vec<_> = binding + .settings + .reads + .iter() + .map(|r| r.at.as_str()) + .collect(); + assert!(reads.contains(&binding.namespace.as_str())); + assert!(reads.contains(&pool.as_str())); + } + Ok(()) +} + +#[test] +fn the_committed_problem_is_a_placeholder_naming_no_problem() -> Result<()> { + let (_, hive) = euler_hive()?; + let problem = hive + .context + .get("context/problem.md") + .ok_or("no problem context")?; + assert!(problem.contains("")); + assert!(problem.contains("placeholder")); + let lower = problem.to_lowercase(); + for named in ["project euler", "projecteuler", "problem 1", "club"] { + assert!(!lower.contains(named), "placeholder names {named:?}"); + } + Ok(()) +} diff --git a/crates/tinyhivemind-lang/tests/gallery_packages.rs b/crates/tinyhivemind-lang/tests/gallery_packages.rs new file mode 100644 index 00000000..2f1d5886 --- /dev/null +++ b/crates/tinyhivemind-lang/tests/gallery_packages.rs @@ -0,0 +1,602 @@ +//! The example gallery (`examples/hives/`) parses and lowers, and keeps the +//! facts each package exists to demonstrate. +//! +//! Every document is read with `include_str!`, exactly as the fixtures are, so +//! the gallery cannot drift from the schema without failing this suite. The +//! web-access check alone reads `examples/hives/` from disk, so a package +//! added later is held to it without being listed here. +use std::collections::BTreeMap; +use tinyhivemind_core::{approval::RuleVerdict, desk::ResponderMode}; +use tinyhivemind_lang::{ + LoweredHive, MemoryKind, MemoryLifecycle, Package, lower, parse, + patch::{self, Operation, Patch, PatchClass}, +}; + +type Result = std::result::Result>; + +/// A package's documents, keyed by package-relative path. +macro_rules! package { + ($dir:literal, [$($path:literal),* $(,)?]) => { + BTreeMap::from([$(( + $path.to_owned(), + include_str!(concat!("../examples/hives/", $dir, "/", $path)).to_owned(), + )),*]) + }; +} + +fn lowered(documents: &BTreeMap) -> Result<(Package, LoweredHive)> { + let package = parse(documents)?; + let lowered = lower(&package)?; + Ok((package, lowered)) +} + +fn seat_ids(hive: &LoweredHive) -> Vec<&str> { + hive.seats.iter().map(|seat| seat.id.as_str()).collect() +} + +fn links(links: &[tinyhivemind_lang::Link]) -> Vec<(&str, &str)> { + links + .iter() + .map(|link| (link.from.as_str(), link.to.as_str())) + .collect() +} + +#[test] +fn review_board_is_one_desk_of_three_with_grounded_quorum() -> Result<()> { + let (_, hive) = review_board()?; + assert_eq!(hive.desks.len(), 1); + assert_eq!(hive.desks[0].id, "board"); + assert_eq!(seat_ids(&hive), ["author", "reviewer-a", "reviewer-b"]); + assert_eq!( + hive.desks[0].members, + ["author", "reviewer-a", "reviewer-b"] + ); + let episode = hive.policies.episode; + assert!(episode.blind_round); + assert_eq!(episode.quorum.threshold, 2); + assert!(episode.quorum.require_grounded); + assert!(hive.topology.is_empty() && hive.referrals.is_empty()); + Ok(()) +} + +#[test] +fn research_debate_carries_goals_missions_and_itemized_context() -> Result<()> { + let (_, hive) = research_debate()?; + assert_eq!(hive.desks[0].responder_mode, ResponderMode::Auto); + let verdict = hive + .goals + .iter() + .find(|goal| goal.id == "verdict") + .ok_or("verdict goal")?; + assert_eq!(verdict.subgoals, ["survey", "critique"]); + let missions: Vec<(&str, &str)> = hive + .missions + .iter() + .map(|mission| (mission.id.as_str(), mission.role.as_str())) + .collect(); + assert_eq!( + missions, + [ + ("decide", "moderator"), + ("survey-prior-work", "librarian"), + ("stress-test", "skeptic") + ] + ); + let brief = hive.context.get("context/brief.md").ok_or("brief")?; + for item in ["latency", "staleness", "prior-work"] { + assert!(brief.contains(&format!("")), "{item}"); + } + Ok(()) +} + +#[test] +fn incident_response_links_two_desks_with_referral_approval_and_asides() -> Result<()> { + let (_, hive) = incident_response()?; + let desks: Vec<(&str, usize)> = hive + .desks + .iter() + .map(|desk| (desk.id.as_str(), desk.members.len())) + .collect(); + assert_eq!(desks, [("triage", 3), ("fixers", 3)]); + assert_eq!(links(&hive.topology), [("triage", "fixers")]); + assert_eq!( + links(&hive.referrals), + [("triage", "fixers"), ("fixers", "triage")] + ); + assert!(hive.policies.referral.enabled); + assert_eq!(hive.policies.referral.max_hops, 2); + let approval = &hive.policies.approval; + assert!(approval.enabled); + assert_eq!(approval.rules.len(), 2); + assert_eq!(approval.rules[1].verdict, RuleVerdict::Ask); + assert!(hive.policies.aside.enabled && hive.policies.aside.must_surface); + Ok(()) +} + +#[test] +fn memory_guild_binds_agent_pool_forked_and_pinned_memories() -> Result<()> { + let (package, hive) = memory_guild()?; + assert_eq!(hive.memory.root, "team:guild"); + assert_eq!(package.constitution.read_only_memories, ["charter"]); + let memory = |id: &str| { + hive.seats + .iter() + .find(|seat| seat.id == id) + .and_then(|seat| seat.memory.as_ref()) + .ok_or(format!("{id} has memory")) + }; + let archivist = memory("archivist")?; + assert_eq!(archivist.agent_id, "archivist"); + assert_eq!(archivist.root, "team:guild"); + assert_eq!(archivist.kind, MemoryKind::Agent); + assert_eq!(archivist.identity.lifecycle, MemoryLifecycle::Hive); + assert_eq!(archivist.settings.reads.len(), 3); + let scout_a = memory("scout-a")?; + assert_eq!(scout_a.identity.fork_parent.as_deref(), Some("lore")); + assert_eq!(scout_a.root, "team:guild/project:lore"); + let scout_b = memory("scout-b")?; + assert_eq!(scout_b.kind, MemoryKind::Pool); + let charter = hive + .memory + .identities + .iter() + .find(|identity| identity.id == "charter") + .ok_or("charter")?; + assert!(charter.read_only); + // Frontmatter is validated against the manifest and stripped on lowering. + let prompt = &hive + .seats + .iter() + .find(|seat| seat.id == "archivist") + .ok_or("archivist")? + .prompt; + assert!(package.documents["seats/archivist.md"].starts_with("---\n")); + assert!(prompt.starts_with("You are the archivist.")); + Ok(()) +} + +#[test] +fn self_improving_constitution_bounds_what_an_editor_may_change() -> Result<()> { + let (package, hive) = self_improving()?; + let constitution = &package.constitution; + assert_eq!( + constitution.permitted_edits, + [PatchClass::Prompt, PatchClass::Context, PatchClass::Goal] + ); + assert_eq!( + constitution.protected_namespaces, + ["team:self/project:heldout"] + ); + assert_eq!(constitution.contamination_canaries, ["HELDOUT-7F3A"]); + assert_eq!(constitution.read_only_memories, ["eval-ledger"]); + assert!(constitution.evaluation.is_object()); + assert!(hive.context.contains_key("context/playbook.md")); + + // A prompt edit is permitted; a policy edit is not. + let before = package.documents["seats/solver.md"].clone(); + let prompt = Patch { + class: PatchClass::Prompt, + operations: vec![Operation::Prompt { + path: "seats/solver.md".into(), + before: before.clone(), + after: format!("{before}Include canceled invoices.\n"), + }], + }; + assert!(patch::apply(&package, &prompt).is_ok()); + let policy = Patch { + class: PatchClass::Policy, + operations: vec![Operation::Json { + pointer: "/manifest/policies/episode/turn_budget".into(), + before: Some(serde_json::json!(9)), + after: Some(serde_json::json!(12)), + }], + }; + assert!(matches!( + patch::apply(&package, &policy), + Err(patch::Error::ForbiddenClass) + )); + Ok(()) +} + +#[test] +fn math_panel_is_five_seats_on_one_desk() -> Result<()> { + let (_, hive) = math_panel()?; + assert_eq!(hive.desks.len(), 1); + assert_eq!( + seat_ids(&hive), + ["conjecturer", "solver-a", "solver-b", "checker", "lead"] + ); + let roles: Vec> = hive + .candidates + .iter() + .map(|candidate| candidate.role.as_deref()) + .collect(); + assert_eq!( + roles, + [ + Some("conjecturer"), + Some("solver"), + Some("solver"), + Some("checker"), + Some("lead") + ] + ); + assert_eq!(hive.policies.episode.quorum.threshold, 3); + Ok(()) +} + +#[test] +fn math_swarm_is_five_linked_desks_of_ten_with_wide_blind_rounds() -> Result<()> { + let (package, hive) = math_swarm()?; + assert_eq!(hive.seats.len(), 50); + let desks = ["conjecture", "compute", "prove", "verify", "synthesis"]; + assert_eq!( + hive.desks + .iter() + .map(|desk| desk.id.as_str()) + .collect::>(), + desks + ); + for desk in &hive.desks { + let expected: Vec = (1..=10).map(|n| format!("{}-{n:02}", desk.id)).collect(); + assert_eq!(desk.members, expected); + } + assert_eq!( + links(&hive.topology), + [ + ("conjecture", "compute"), + ("compute", "prove"), + ("prove", "verify"), + ("verify", "synthesis"), + ("synthesis", "conjecture") + ] + ); + assert_eq!(hive.referrals.len(), 5); + assert!( + hive.referrals + .iter() + .filter(|link| link.to == "verify") + .count() + >= 3 + ); + let episode = hive.policies.episode; + assert_eq!((episode.round_width, episode.revealed_width), (10, 10)); + assert_eq!(episode.quorum.threshold, 26); + assert!(episode.quorum.require_grounded); + assert_eq!(package.constitution.max_width, 10); + assert!(u64::from(episode.turn_budget) <= package.constitution.max_turns); + Ok(()) +} + +/// Tool names that reach the web; no gallery seat may be allowed one. +const WEB_TOOLS: [&str; 4] = ["web_search", "web_fetch", "browser", "http"]; + +/// The one gallery package that opts in to web research: `euler-hive`'s +/// research desk searches the public literature (through the host's search +/// tool) for the theory behind a problem. Every other package, and every +/// other euler-hive seat, stays off the web. +const WEB_RESEARCH_PACKAGE: &str = "euler-hive"; +/// The euler-hive desk whose seats may hold `web_search`. +const WEB_RESEARCH_DESK: &str = "research"; + +/// Every package directory under `examples/hives/`, read from disk the way a +/// host loads it, so a package added later is checked without being listed. +fn gallery_from_disk() -> Result> { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/hives"); + let mut dirs: Vec<_> = std::fs::read_dir(&root)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>()?; + dirs.retain(|path| path.join("hive.json").is_file()); + dirs.sort(); + let mut packages = Vec::new(); + for dir in dirs { + let mut documents = BTreeMap::new(); + read_package(&dir, &dir, &mut documents)?; + let name = dir + .file_name() + .ok_or("package directory has a name")? + .to_string_lossy() + .into_owned(); + let (_, hive) = lowered(&documents).map_err(|error| format!("{name}: {error}"))?; + packages.push((name, hive)); + } + Ok(packages) +} + +fn read_package( + root: &std::path::Path, + dir: &std::path::Path, + into: &mut BTreeMap, +) -> Result<()> { + for entry in std::fs::read_dir(dir)? { + let path = entry?.path(); + if path.is_dir() { + read_package(root, &path, into)?; + continue; + } + let key: Vec = path + .strip_prefix(root)? + .components() + .map(|part| part.as_os_str().to_string_lossy().into_owned()) + .collect(); + into.insert(key.join("/"), std::fs::read_to_string(&path)?); + } + Ok(()) +} + +#[test] +fn no_gallery_seat_may_reach_the_web_outside_euler_hives_research_desk() -> Result<()> { + let packages = gallery_from_disk()?; + for listed in [ + "incident-response", + "math-panel", + "math-swarm", + "memory-guild", + "research-debate", + "review-board", + "self-improving", + ] { + assert!( + packages.iter().any(|(name, _)| name == listed), + "{listed} is read" + ); + } + for (name, hive) in &packages { + assert!(!hive.seats.is_empty(), "{name}"); + let researchers: Vec<&str> = if name == WEB_RESEARCH_PACKAGE { + hive.desks + .iter() + .filter(|desk| desk.id == WEB_RESEARCH_DESK) + .flat_map(|desk| desk.members.iter().map(String::as_str)) + .collect() + } else { + Vec::new() + }; + for seat in &hive.seats { + let allowed: Vec<&str> = WEB_TOOLS + .into_iter() + .filter(|tool| seat.tools.allow.iter().any(|allowed| allowed == tool)) + .collect(); + if researchers.contains(&seat.id.as_str()) { + assert!( + allowed.iter().all(|tool| *tool == "web_search"), + "{name}/{} may search but allows {allowed:?}", + seat.id + ); + } else { + assert!(allowed.is_empty(), "{name}/{} allows {allowed:?}", seat.id); + } + for tool in WEB_TOOLS { + if allowed.contains(&tool) { + continue; + } + assert!( + seat.tools.deny.iter().any(|denied| denied == tool), + "{name}/{} does not deny {tool}", + seat.id + ); + } + } + } + Ok(()) +} + +/// The persona a seat prompt opens with: `You are a "".` +fn persona(prompt: &str) -> Option<&str> { + prompt + .lines() + .next()? + .strip_prefix("You are a \"")? + .strip_suffix("\".") + .filter(|persona| !persona.trim().is_empty()) +} + +#[test] +fn every_math_seat_opens_with_a_distinct_persona() -> Result<()> { + let (_, swarm) = math_swarm()?; + let mut seen = std::collections::BTreeSet::new(); + for seat in &swarm.seats { + let persona = persona(&seat.prompt).ok_or(format!("{} has no persona line", seat.id))?; + assert!( + seen.insert(persona.to_lowercase()), + "{} repeats the persona {persona:?}", + seat.id + ); + assert!( + seat.prompt.lines().count() >= 3, + "{} explains how it thinks", + seat.id + ); + } + assert_eq!(seen.len(), 50); + let (_, panel) = math_panel()?; + for seat in &panel.seats { + let persona = persona(&seat.prompt).ok_or(format!("{} has no persona line", seat.id))?; + assert!( + seen.insert(persona.to_lowercase()), + "{} repeats a swarm persona", + seat.id + ); + } + assert_eq!(seen.len(), 55); + Ok(()) +} + +fn incident_response() -> Result<(Package, LoweredHive)> { + lowered(&package!( + "incident-response", + [ + "hive.json", + "constitution.json", + "memory.json", + "roles/fixer.md", + "roles/incident-lead.md", + "roles/release-reviewer.md", + "roles/triager.md", + "seats/fixer-db.md", + "seats/fixer-net.md", + "seats/incident-lead.md", + "seats/release-reviewer.md", + "seats/triager-logs.md", + "seats/triager-metrics.md", + ] + )) +} + +fn math_panel() -> Result<(Package, LoweredHive)> { + lowered(&package!( + "math-panel", + [ + "hive.json", + "constitution.json", + "memory.json", + "roles/checker.md", + "roles/conjecturer.md", + "roles/lead.md", + "roles/solver.md", + "seats/checker.md", + "seats/conjecturer.md", + "seats/lead.md", + "seats/solver-a.md", + "seats/solver-b.md", + ] + )) +} + +fn math_swarm() -> Result<(Package, LoweredHive)> { + lowered(&package!( + "math-swarm", + [ + "hive.json", + "constitution.json", + "memory.json", + "roles/computer.md", + "roles/conjecturer.md", + "roles/prover.md", + "roles/synthesizer.md", + "roles/verifier.md", + "seats/compute-01.md", + "seats/compute-02.md", + "seats/compute-03.md", + "seats/compute-04.md", + "seats/compute-05.md", + "seats/compute-06.md", + "seats/compute-07.md", + "seats/compute-08.md", + "seats/compute-09.md", + "seats/compute-10.md", + "seats/conjecture-01.md", + "seats/conjecture-02.md", + "seats/conjecture-03.md", + "seats/conjecture-04.md", + "seats/conjecture-05.md", + "seats/conjecture-06.md", + "seats/conjecture-07.md", + "seats/conjecture-08.md", + "seats/conjecture-09.md", + "seats/conjecture-10.md", + "seats/prove-01.md", + "seats/prove-02.md", + "seats/prove-03.md", + "seats/prove-04.md", + "seats/prove-05.md", + "seats/prove-06.md", + "seats/prove-07.md", + "seats/prove-08.md", + "seats/prove-09.md", + "seats/prove-10.md", + "seats/synthesis-01.md", + "seats/synthesis-02.md", + "seats/synthesis-03.md", + "seats/synthesis-04.md", + "seats/synthesis-05.md", + "seats/synthesis-06.md", + "seats/synthesis-07.md", + "seats/synthesis-08.md", + "seats/synthesis-09.md", + "seats/synthesis-10.md", + "seats/verify-01.md", + "seats/verify-02.md", + "seats/verify-03.md", + "seats/verify-04.md", + "seats/verify-05.md", + "seats/verify-06.md", + "seats/verify-07.md", + "seats/verify-08.md", + "seats/verify-09.md", + "seats/verify-10.md", + ] + )) +} + +fn memory_guild() -> Result<(Package, LoweredHive)> { + lowered(&package!( + "memory-guild", + [ + "hive.json", + "constitution.json", + "memory.json", + "roles/archivist.md", + "roles/scout.md", + "roles/scribe.md", + "seats/archivist.md", + "seats/scout-a.md", + "seats/scout-b.md", + "seats/scribe.md", + ] + )) +} + +fn research_debate() -> Result<(Package, LoweredHive)> { + lowered(&package!( + "research-debate", + [ + "hive.json", + "constitution.json", + "memory.json", + "roles/advocate.md", + "roles/librarian.md", + "roles/moderator.md", + "roles/skeptic.md", + "seats/advocate.md", + "seats/librarian.md", + "seats/moderator.md", + "seats/skeptic.md", + "context/brief.md", + ] + )) +} + +fn review_board() -> Result<(Package, LoweredHive)> { + lowered(&package!( + "review-board", + [ + "hive.json", + "constitution.json", + "memory.json", + "roles/author.md", + "roles/reviewer.md", + "seats/author.md", + "seats/reviewer-a.md", + "seats/reviewer-b.md", + ] + )) +} + +fn self_improving() -> Result<(Package, LoweredHive)> { + lowered(&package!( + "self-improving", + [ + "hive.json", + "constitution.json", + "memory.json", + "roles/critic.md", + "roles/editor.md", + "roles/solver.md", + "seats/critic.md", + "seats/editor.md", + "seats/solver.md", + "context/playbook.md", + ] + )) +} diff --git a/crates/tinyhivemind-openhuman/examples/live_language.rs b/crates/tinyhivemind-openhuman/examples/live_language.rs index 93da653b..ed523ccf 100644 --- a/crates/tinyhivemind-openhuman/examples/live_language.rs +++ b/crates/tinyhivemind-openhuman/examples/live_language.rs @@ -158,7 +158,7 @@ async fn run(input: String, output: String) -> Result<()> { ) .await?; let host = OpenHumanHost::new(runtime.runtime_id().into(), coordinator.clone())? - .with_turn_timeout(Duration::from_secs(180))? + .with_turn_timeout(Duration::from_mins(3))? .with_management(Arc::new(Factory(Arc::new(runtime))), Arc::new(AllowHost))?; for seat in &lowered.seats { host.manage("host", language::management_request(seat)) diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/cancellation.rs b/crates/tinyhivemind-openhuman/src/deploy/test/cancellation.rs index 6a539c76..027234dd 100644 --- a/crates/tinyhivemind-openhuman/src/deploy/test/cancellation.rs +++ b/crates/tinyhivemind-openhuman/src/deploy/test/cancellation.rs @@ -34,7 +34,7 @@ impl tinytools::Tool for SlowTool { let output = openhuman_embed::process::command_output( &mut command, Vec::new(), - Duration::from_secs(7200), + Duration::from_hours(2), ) .await?; Ok(tinytools::ToolResult::success(String::from_utf8_lossy( @@ -109,7 +109,7 @@ fn expired_turn_reaps_a_registered_command_before_returning() -> anyhow::Result< .await?; let coordinator = deployment.host().coordinator().clone(); let task = tokio::spawn(async move { coordinator.run_until_idle().await }); - let pid = tokio::time::timeout(Duration::from_secs(60), async { + let pid = tokio::time::timeout(Duration::from_mins(1), async { loop { if let Ok(pid) = std::fs::read_to_string(&marker) && !pid.is_empty() diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/construction.rs b/crates/tinyhivemind-openhuman/src/deploy/test/construction.rs index 94bc8647..820e942b 100644 --- a/crates/tinyhivemind-openhuman/src/deploy/test/construction.rs +++ b/crates/tinyhivemind-openhuman/src/deploy/test/construction.rs @@ -214,7 +214,7 @@ fn retiring_a_seat_purges_its_agent_home_and_leaves_its_hives() -> anyhow::Resul ); assert!(deployment.runtime().agent_ids().contains(&agent_id)); let mut deployment = deployment; - deployment.retire_seat("alice", Purge::Both).await?; + Box::pin(deployment.retire_seat("alice", Purge::Both)).await?; assert!(!deployment.seats().contains_key("alice")); assert!(!deployment.runtime().agent_ids().contains(&agent_id)); assert!( diff --git a/crates/tinyhivemind-openhuman/src/deploy/test/profile.rs b/crates/tinyhivemind-openhuman/src/deploy/test/profile.rs index 3a262d04..2d22ce97 100644 --- a/crates/tinyhivemind-openhuman/src/deploy/test/profile.rs +++ b/crates/tinyhivemind-openhuman/src/deploy/test/profile.rs @@ -145,7 +145,7 @@ fn native_definition_retains_bare_prompt_sampling_tools_skills_and_mcp_credentia assert!( matches!(definition.system_prompt, openhuman_core::agent::harness::definition::PromptSource::Verbatim(ref prompt) if prompt == "Seat prompt\nProfile context\nSeat context") ); - assert_eq!(definition.temperature, 0.2); + assert_eq!(definition.temperature.to_bits(), 0.2_f64.to_bits()); assert_eq!(definition.max_iterations, 2); assert!( matches!(definition.tools, openhuman_core::agent::harness::definition::ToolScope::Named(ref names) if names.contains(&"file_read".into())) diff --git a/crates/tinyhivemind-openhuman/src/host/hooks_test.rs b/crates/tinyhivemind-openhuman/src/host/hooks_test.rs index 92a013c4..137337c3 100644 --- a/crates/tinyhivemind-openhuman/src/host/hooks_test.rs +++ b/crates/tinyhivemind-openhuman/src/host/hooks_test.rs @@ -155,7 +155,7 @@ fn the_turn_timeout_is_configurable_and_nonzero() { )); let host = host.with_turn_timeout(Duration::from_millis(20)).unwrap(); assert_eq!(host.turn_timeout(), Duration::from_millis(20)); - let provider = provider(Duration::from_secs(60)).await; + let provider = provider(Duration::from_mins(1)).await; host.register_agent(runtime.agent(spec("slow", &provider)).unwrap()) .await .unwrap(); diff --git a/crates/tinyhivemind-openhuman/src/host/types.rs b/crates/tinyhivemind-openhuman/src/host/types.rs index da567f1c..515b61d8 100644 --- a/crates/tinyhivemind-openhuman/src/host/types.rs +++ b/crates/tinyhivemind-openhuman/src/host/types.rs @@ -5,7 +5,7 @@ use std::{future::Future, path::PathBuf, pin::Pin, sync::Arc, time::Duration}; use tinyhivemind_hives::{Destination, EpisodeContext, HiveInfo, TurnDisposition, TurnRequest}; use tinyhivemind_lang::LoweredMemoryBinding; /// Default maximum duration of a supplied agent turn. -pub const TURN_TIMEOUT: Duration = Duration::from_secs(300); +pub const TURN_TIMEOUT: Duration = Duration::from_mins(5); /// Host factory result future. pub type AgentFuture = Pin> + Send>>; /// Host creates configured agents from nonsecret template references. diff --git a/docs/adr/0032-hive-language-owns-the-wire-format.md b/docs/adr/0032-hive-language-owns-the-wire-format.md index 9cc8a0ba..8a2505c5 100644 --- a/docs/adr/0032-hive-language-owns-the-wire-format.md +++ b/docs/adr/0032-hive-language-owns-the-wire-format.md @@ -3,6 +3,8 @@ - **Status:** Accepted - **Date:** 2026-10-10 - **Supersedes:** [ADR 0028](0028-three-crate-workspace.md) package count +- **Superseded in part by:** [ADR 0033](0033-editors-write-files-and-the-crate-checks-them.md), + which replaces patch authoring with direct file edits that the crate checks ## Decision diff --git a/docs/adr/0033-editors-write-files-and-the-crate-checks-them.md b/docs/adr/0033-editors-write-files-and-the-crate-checks-them.md new file mode 100644 index 00000000..77268a3d --- /dev/null +++ b/docs/adr/0033-editors-write-files-and-the-crate-checks-them.md @@ -0,0 +1,65 @@ +# 33. Editors write files; the crate checks them + +- **Status:** Accepted — supersedes the operation-authoring part of the + editing model in [ADR 0032](0032-hive-language-owns-the-wire-format.md) and + [the hive language spec](../specs/hive-language.md#edits-and-archive) +- **Date:** 2026-10-11 +- **Specification:** [checking direct edits](../specs/hive-language-checking.md) + +## Context + +The hive language let an editor change a package only by authoring a +`Patch`: one class, and operations carrying exact before/after +preconditions — a JSON Pointer with the whole old value, a prompt body +reproduced byte for byte, a context item's exact previous text. `apply` built +the candidate and `guard` enforced the constitution, stopping at the first +violation. + +That is the wrong interface for an orchestrator or master agent. Agents are +good at editing files and poor at reproducing preconditions exactly; a +one-character drift in `before` turns a sound edit into a `Stale` refusal. +An edit that spans two classes, such as adding a seat and its memory binding, +needs two hand-built patches in the right order. And a refusal named one +problem without saying where it was, so an agent fixed problems one round +trip at a time. + +## Decision + +Editors write files directly, as they want. The crate checks them the way a +compiler checks source. + +- `check(original, edited)` over two document maps, and `check_packages` over + two packages, run every language and constitution rule and return + `Result>`. +- A `Diagnostic` is located — file, JSON Pointer, rule family, message — and + every finding is collected rather than the first. `diagnose` and + `diagnose_documents` do the same for one package. `validate` and `parse` + keep their signatures and return the first finding as the error they always + returned. +- The checker infers the edit classes from the files that changed. A change + of a class the constitution does not permit, or one no class owns, is a + finding, never silently accepted. Every guard rule — an unchanged + constitution, protected keys, frontmatter, canaries, read-only memories, + full validation — applies, located. +- On success the `Review` carries one derived `Patch` per touched class, in an + order valid at every step. The `Patch` wire, `apply`, `guard` and `diff` are + unchanged: a patch is now the *recorded* form of an edit that lineage + stores, derived by the checker. Memory learning operations are not files + and are still proposed as patches. + +## Consequences + +- An editor's interface is the package's own files, which it can read, + rewrite whole and re-check until clean; the `hive_check` example is that + loop as a command. +- One call reports every problem, each where it lives, so a refused edit can + be fixed in one round. +- A multi-class edit becomes several lineage records, one per class, each with + its own content identity; the last is the edited package's. A host that + evaluates only the whole edit gives each step the whole's verdict. +- An edit whose per-class steps cannot each be valid on their own (a new seat + whose prompt frontmatter restates a binding that a memory change adds) is + refused with a `sequence` finding and must be split. This is the cost of + keeping lineage per class. +- Frontmatter stays immutable through an edit, as `diff` already required: + it restates the manifest, which is the field to change. diff --git a/docs/adr/README.md b/docs/adr/README.md index 576114ec..54d6ba28 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,7 +57,8 @@ link any earlier ADR it amends. | [0030](0030-a-host-memory-port-feeds-seat-sessions.md) | A host memory port feeds seat sessions | Accepted — supersedes recall.md's no-port and no-index non-goals for host-owned memory | | [0031](0031-expose-host-seams-over-async-incremental-storage.md) | Expose host seams over async, incremental storage | Accepted | -| [0032](0032-hive-language-owns-the-wire-format.md) | The hive language owns a host-neutral wire format | Accepted — supersedes the package count in 0028 | +| [0032](0032-hive-language-owns-the-wire-format.md) | The hive language owns a host-neutral wire format | Accepted — supersedes the package count in 0028; editing superseded in part by 0033 | +| [0033](0033-editors-write-files-and-the-crate-checks-them.md) | Editors write files; the crate checks them | Accepted — supersedes patch authoring in 0032 | ## Reading order diff --git a/docs/experiments/2026-10-11-euler-hive-live.md b/docs/experiments/2026-10-11-euler-hive-live.md new file mode 100644 index 00000000..5f01ec6c --- /dev/null +++ b/docs/experiments/2026-10-11-euler-hive-live.md @@ -0,0 +1,201 @@ +# A hive-language hive on two live Euler problems + +**Date:** 2026-10-11. **Status:** Recorded. +**Code:** `cargo run --manifest-path examples/openhuman/Cargo.toml --bin euler_hive -- --problem --problem-id --runs hive-runs [--max-turns 80]` +(see [`euler_hive/README.md`](../../examples/openhuman/src/bin/euler_hive/README.md)). +**Package:** [`euler-hive`](../../crates/tinyhivemind-lang/examples/hives/euler-hive/README.md). +**Spec:** [hive language](../specs/hive-language.md), [hive memory](../specs/hive-memory.md), +[cross-desk referral](../specs/cross-desk-referral.md). + +This record is redacted, as Project Euler's rules require. It gives no +answer, no number or data from either statement, and no value the hive +proposed. Run directories, the problem statements and both oracles stay +under the git-ignored `hive-runs/`. + +## The question + +Can a hive that is defined entirely in the hive language — desks, personas, +tool access, topology, referral policy and memory bindings — be lowered into +live OpenHuman agents and attempt a hard problem end to end? We counted it a +success only if the hive committed the answer that a hidden oracle computes +independently. + +The setup had these pieces: + +- **Package.** Fourteen persona seats on five desks run as a pipeline: + research, theory, compute, verify, synthesis. +- **Models.** Every model call went through OpenRouter, using + `google/gemini-3.8-flash`. +- **Web search.** Research seats only, through the TinyHumans SDK + (Parallel, and Gemini with Google Search grounding). +- **Python.** Compute and verify seats only, in a fresh Docker container + per program, with no network and nothing mounted. +- **Memory.** Every seat was bound to CortexDB behind the TinyHumans backend. +- **Routing.** Each desk ran on its own core `CompletionDriver` floor. Desks + reached each other only through core's `referral` fold. + +The two problems were PE1014 (a combinatorial design) and PE1012 (an +equilibrium sum the user rated harder). + +## The oracles + +Neither oracle is in any prompt, context or memory, and the sandbox can mount +nothing, so the hive could not reach them. + +- **PE1014.** The oracle rebuilt the whole design from the given blocks and + checked three things: there was exactly one completion, it had 759 blocks, + and every 5-set was covered exactly once. The answer block satisfied every + constraint in the statement. +- **PE1012.** The oracle solved each `n` exactly in rationals up to 2,000, + checking every equilibrium condition. It then solved up to the target in + double precision with iterative refinement. A 40-digit fallback took over + where the support changes (one such `n`). Five widely spaced `n` checked + at 40 digits differed from the float values by at most 4e-16. The float sum + matched the exact sum at 2,000 to 4e-15, and it reproduces every sample + value in the statement. We treat it as trustworthy to the requested + precision. + +## Runs + +| Run | Problem | Ending | Correct | Turns | Model calls | Tokens in / out | OpenRouter | Search | Wall | Sandbox | Searches | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| smoke 1 (`deepseek/deepseek-v4-pro`) | 1014 | turn cap (8) | — | 8 | 32 | 154k / 57k | $0.08 | $0.02 | 15 min | 1 | 2 | +| smoke 2 | 1014 | turn cap (16) | — | 15 | 46 | 207k / 19k | $0.15 | $0.07 | 17 min | 6 | 3 | +| full 1 | 1014 | converged | **yes** | 18 | 67 | 351k / 33k | $0.27 | $0.12 | 14 min | 13 | 10 | +| full 1 | 1012 | idle (host defect) | — | 17 | 78 | 875k / 62k | $0.62 | $0.12 | 26 min | 25 | 12 | +| full 2 | 1012 | converged | no | 21 | 99 | 1.04M / 64k | $0.65 | $0.07 | 23 min | 40 | 5 | +| full 3 | 1012 | converged | no | 52 | 211 | 3.10M / 172k | $1.80 | $0.09 | 51 min | 60 | 4 | +| full 4 (final code) | 1014 | converged | **yes** | 21 | 70 | 340k / 36k | see below | $0.04 | 18 min | 15 | 4 | +| full 4 (final code) | 1012 | no decision | (none committed) | 48 | 184 | 2.28M / 135k | $2.09 for both full-4 runs | $0.09 | 53 min | 60 | 12 | + +The two full-4 runs ran at the same time on one key, so their account deltas +overlap. $2.09 covers both. Over every run, including probes, the total was +about $5.7 of OpenRouter and $0.6 of search. + +The OpenRouter column is the key's own account delta for the run. OpenHuman +reported a cost of zero for this model, so the host meters spend from the +account instead. Wall time includes runtime boot. In smoke 1, before the +keychain fix described below, boot took about five minutes. + +## PE1014: what happened + +The run was correct on the first full attempt: 18 turns, about $0.39 +including search, 14 minutes. + +- Research named the structure, but two seats also searched for the + problem's own data strings. No search result contained the answer; that + behaviour is why the query guard below exists. +- One research seat asked theory, and theory asked compute. Both answers came + home by core's `Return`. +- The candidate first appeared in the printed output of a compute program. + Four more compute programs and nine verify programs reproduced it. +- All three verify seats published to the verified pool, and synthesis + committed. + +All eight referrals were accepted: two asks, two returns, and four pipeline +reports at hops 1 to 4. + +## PE1012: what happened + +Every PE1012 run reproduced the statement's sample values and the structure +of the support. Its boundaries matched the oracle's. No run committed the +correct sum. + +- **Run 1** went idle after 17 turns. Two research seats asked theory at the + same time, and each theory answer asked compute. The host's single + resume slot per desk then dropped one answer's way home, so research waited + forever. This was a host defect and is fixed below. +- **Run 2** converged on a value the oracle rejects. Compute spent all 40 + sandbox runs, so the verify desk ran nothing and still signed off. +- **Run 3** ran with the chain and sandbox fixes. Verify reserved and spent 20 programs, and + the support gate refused one ungrounded support. No verify seat supported a + candidate. Synthesis committed a value anyway, against its own charter, and + the oracle rejects it. The host now refuses that; see below. +- **Run 4** ran with every fix and ended `no_decision`. Compute and theory + converged on a wrong value. All five verify verdicts on it were + grounded objections, and the host refused synthesis's attempt to commit it + anyway. On a problem the hive could not solve, it now reports no decision + instead of a wrong answer. + +The PE1014 rerun on the final code (full 4) was correct again. Its research +seats had two literal-data queries refused by the guard. + +## Memory, read back from CortexDB + +These counts are read from the store after each run, per seat namespace +`team:euler-hive/ws:/agent:`. They are not host counters. + +| Run | Conversation items (OpenHuman's own turn log) | Learnings (host `Remember` and `hive_remember`) | Verified-pool writes | Notes recalled during the run | Notes a post-run recall finds | +| --- | --- | --- | --- | --- | --- | +| 1014 full 1 | 36 | 21 | 3 | 0 | not measured | +| 1012 full 2 | 46 | 27 | 0 | 0 | 27 | +| 1012 full 3 | 113 | 56 | 0 | 55 | 48 | +| 1014 full 4 | 41 | 25 | 3 | 0 | 67 | +| 1012 full 4 | 106 | 52 | 0 | 0 | 46 | + +Every seat holds both kinds. Early in-run recall was empty by design: a +binding reads only the seat's own namespace and the verified pool, and a +rejoin recalls only what *other* seats wrote since. Recall grows as the pool +and the sessions fill. That is the 55 notes recalled in PE1012 run 3. + +## What the runs changed in the host + +Each fix below has a regression test in `euler_hive/test.rs`. + +- **Overlapping questions lost an answer.** Work on a desk is now a *chain* + with its own id. A question owes its chain one answer, and the answer + reopens the chain on the asking seat when it runs. A chain reports only + once nothing it asked is still owed. + (`overlapping_questions_with_nested_asks_all_come_home_and_the_pipeline_finishes`) +- **Compute starved verify of sandbox runs.** A third of the sandbox budget + is now held for the verify desk, reserved atomically. +- **Ungrounded support.** A verify seat's `support` is refused unless it ran + a `run_python` that executed during that turn. +- **Committing an unverified answer.** Synthesis's candidate becomes the + hive's answer only if a verify seat supported it. Otherwise the run ends + `no_decision`. +- **Research looked the problem up.** A query that quotes the statement's + own data (long numbers, upper-case data words) or looks up the problem's + source is refused before it is sent. In PE1012 run 1 a search had already + surfaced a published derivation of the problem's closed form; it carried + no final value. +- **Boot stall.** OpenHuman's secret store waited minutes on the OS + keychain. Upstream now keeps secrets in memory for an embed host, and the + vendored OpenHuman was moved to that release. +- **Exa and TinyFish refuse API keys.** The TinyHumans backend answers an + API-key caller with 403 on Exa and 404 on TinyFish, so seats are offered + Parallel and Gemini. `--probe` records both refusals. + +## In the seats' own words + +Excerpts are trimmed, and every answer and data string is redacted: + +> **research-librarian** (PE1014): "The main problem describes the Witt design +> …, the unique Steiner system with [the statement's] parameters." + +> **theory-counter** (PE1014): "Auditing the combinatorial counting and +> parameters of the Steiner system … Any two distinct blocks intersect in 0, +> 2, or 4 elements, matching the weight properties." + +> **verify-rederiver** (PE1014): "Sample validation: … Filtering all +> 3-subsets … uniquely yields [the sample's block], matching the problem +> statement." + +> **theory-counter** (PE1012): "Brilliant breakthrough on the mechanism of +> support intervals! … The lower bound cannot advance further once the +> element immediately below gets positive expected payoff." + +## What this does and does not show + +It shows that a hive-language package can be lowered into live, tool-gated, +memory-bound OpenHuman agents on desk floors joined only by referral. A +14-seat hive solved a design problem correctly, twice, for about $0.40 a run. + +On the harder problem it reproduced every sample and the right structure, but +never reached the right sum. Its verification desk was the weak point: first +it was starved of runs, then it was ignored. Both are now enforced by the +host instead of asked of the model. With them in place, the hive's verdict on +its own wrong answer was "no decision". + +These are single runs of one model, not a benchmark. Nothing here measures +the hive against a single agent at matched budget. diff --git a/docs/experiments/2026-10-11-math-hive-scale.md b/docs/experiments/2026-10-11-math-hive-scale.md new file mode 100644 index 00000000..448fdfb5 --- /dev/null +++ b/docs/experiments/2026-10-11-math-hive-scale.md @@ -0,0 +1,184 @@ +# One seat, a panel of five, a swarm of fifty + +**Date:** 2026-10-11. **Status:** Recorded. +**Code:** `hive_view --compare --seeds 50 --table` over the `math-panel` and +`math-swarm` gallery packages (command below). +**Spec:** [hive language](../specs/hive-language.md). +**Decision:** none; this record feeds the wiki's +[Hive gallery](https://github.com/tinyhumansai/tinyhivemind/wiki/Hive-gallery) +page. + +## Question + +Given the same exact problems and seats drawn from the same accuracy ranges, +does a hive decide more accurately as it grows from one seat to five to +fifty? The expected ordering, written down before the run, was swarm > panel > +single seat. + +## Setup + +This is an offline simulation. No model is called. Each package is parsed and +lowered by `tinyhivemind-lang`, then driven by core's `hive::step` through the +`hive_view` example. Seats write scripted trace markers whose content comes +from a seeded error model. + +- **Problems.** Eight small problems with exact answers, defined in each + package's `sim.json`: two lattice-path counts, two modular power sums, a + digit sum, multiples of 3 or 5, a square difference, and a prime count. + Each seed picks one problem, and every package solves that same problem on + that seed. Correct means equal to the exact computation in + `examples/hive_view/math.rs`. +- **Error model.** Each seat draws an accuracy from its role's range with + the seed and gets the answer right with that probability. Otherwise it + writes the problem's classic mistake (500,000 ppm) or the answer plus one + or minus one (250,000 ppm each). A `slice` seat that misses posts the + answer modulo its slice instead of a wrong answer. +- **Accuracy ranges, in parts per million.** These are shared by both + packages and were fixed before any run. + + | Role | Behaviour | Range | Packages | + | --- | --- | --- | --- | + | conjecturer | `guess` | 250,000 – 450,000 | panel, swarm | + | solver / prover | `solve` | 450,000 – 750,000 | panel, swarm, baseline | + | checker / verifier | `check` | 500,000 – 800,000 | panel, swarm | + | lead / synthesizer | `lead` | 450,000 – 750,000 | panel, swarm | + | computer | `slice` | 300,000 – 600,000 | swarm only | + + Swarm slices are the moduli 3, 4, 5, 7, 8, 9, 11, 13, 16, and 25. +- **Single seat.** One `solve` seat on a one-member desk with a quorum of + one, using the `baseline` entry of `sim.json`. +- **math-panel.** One desk with a conjecturer, two solvers, a checker, and a + lead. Blind opening round of five, revealed width five, turn budget 15, + grounded quorum of 3 within a 15-turn window. Constitution caps of 60 turns + and width 8. +- **math-swarm.** Five desks of ten (conjecture, compute, prove, verify, + synthesis). Blind and revealed width ten, turn budget 150, grounded quorum + of 26 (`EpisodePolicy::for_room(50)`) within a 150-turn window. + Constitution caps of 200 turns and width 10. Its topology and referrals are + declared, but `hive_view` runs a multi-desk package as one combined floor, + so they are drawn and not routed, and the quorum is hive-wide. +- **Sample.** Seeds 1 to 50, one episode per package per seed. + +## Result + +> The math-swarm row below was measured on one combined floor and is +> superseded by [Update: routed desks and live runs](#update-routed-desks-and-live-runs). +> The single-seat and math-panel rows are unchanged. + +```sh +cargo run --release -p tinyhivemind-lang --example hive_view -- \ + crates/tinyhivemind-lang/examples/hives/math-swarm --compare --seeds 50 --table +``` + +```text +50 seeds, same problem per seed for every package; correct = exact answer + +package seats accuracy converged deadlocked exhausted idle rounds turns ms/episode +single-seat 1 0.640 1.000 0.000 0.000 0.000 2.00 2.00 0.011 +math-panel 5 0.580 0.720 0.000 0.280 0.000 2.30 8.62 0.058 +math-swarm 50 1.000 1.000 0.000 0.000 0.000 6.66 54.00 0.549 +``` + +`ms/episode` is wall-clock time from one release build and varies between +machines. Every other column is deterministic for this seed set. The same +numbers, with a different wall clock, are pinned in +`crates/tinyhivemind-lang/examples/hive_view/README.md`. + +## Findings + +The expected ordering held only for the swarm. + +1. **The swarm decided correctly on all 50 seeds**, against 64% for one + seat. It converged every time, on a mean of 54 of its 150 turns over 6.66 + rounds. +2. **The five-seat panel was below one seat, at 58%.** The cause is + termination: 28% of panel episodes exhausted their fifteen-turn budget with + every advocate silenced by a grounded objection, and an undecided episode + counts as incorrect. Two mechanisms produce this: + - Wrong answers are correlated. Half of all wrong derivations land on the + problem's single classic mistake, so wrong seats agree with each other + rather than scattering. + - A wrong checker silences correct advocates. The checker objects to up to + two advocates of rival answers per turn. When it is wrong, its grounded + objections remove the correct answer's support. +3. **The swarm's advantage is partly composition, not size alone.** Its ten + `computer` seats are exact on a residue slice and never claim more than + they know, so they can rule out the plus-or-minus-one mistakes. The panel + has no such seat. This run can't separate the effect of fifty seats from + the effect of those exact calculators. A swarm without computers, or a + panel with one, would be the next arm to run. + +## What this does not show + +These are simulated seats under a stated error model. The run measures how the +protocol (blind rounds, grounded objection, quorum, and the turn budget) +behaves under that model. It says nothing about the quality of any real model. +The ranges were chosen once, not fitted, and a different error model could +reorder the panel and the single seat. The swarm's desk structure is not +exercised, because the offline loop runs every seat on one floor and routes no +referrals. + +## What should change + +- The panel's fifteen-turn budget with a quorum of three is too tight to + recover from a wrong checker. A larger budget or a revealed round that lets + a silenced advocate re-ground its claim is worth measuring before the + panel is presented as a working configuration. +- Add the two ablation arms in finding 3 before claiming that size, rather + than the residue calculators, carries the swarm's result. + +## Update: routed desks and live runs + +**Date:** 2026-10-11, later the same day. The sections above are the original +record and are kept as written; this section supersedes the math-swarm +numbers in them. + +`hive_view` now runs a multi-desk package as one floor per desk: each desk has +its own journal and episode and takes its share of the package's quorum and +budget (for math-swarm, a quorum of 6 of 10 and 30 of the 150 turns). Desks +reach each other only through core's referral fold, along declared links, in +pipeline order (`conjecture`, `compute`, `prove`, `verify`, `synthesis`), +within `max_hops: 4`; synthesis's ending is the hive's. The error model, +ranges, problems and seeds are unchanged. Same command, release build: + +```text +50 seeds, same problem per seed for every package; correct = exact answer + +package seats accuracy converged deadlocked exhausted idle rounds turns ms/episode +single-seat 1 0.640 1.000 0.000 0.000 0.000 2.00 2.00 0.009 +math-panel 5 0.580 0.720 0.000 0.280 0.000 2.30 8.62 0.044 +math-swarm 50 0.980 1.000 0.000 0.000 0.000 12.54 84.90 1.207 +``` + +**How finding 1 changes.** The routed swarm was right on 49 of 50 seeds +(0.98), not all 50, and still converged on every seed. It spent a mean of 85 +turns over 12.5 rounds instead of 54 over 6.66: every desk holds its own blind +round, and the five desks decide one after another, each on a quorum of six +of its own ten seats rather than 26 of fifty. The ordering is unchanged +(swarm well above one seat, panel below it), and findings 2 and 3 stand as +written. The caveat in "What this does not show", that the desk structure is +not exercised, no longer holds offline. + +**Live runs.** `--live` makes every seat turn a real OpenRouter chat +completion, with core still deciding every round and ending and the desk +router still routing every referral. Requests carry no `tools`, `plugins` or +web-search options, and model ids containing `online`, `search`, `sonar` or +`web` are refused. Seed 1, `openai/gpt-oss-120b:nitro`, one run each: + +| Package | Problem | Outcome | Calls | Tokens | Cost | Wall | +| --- | --- | --- | --- | --- | --- | --- | +| math-panel | lattice-5x8 | correct | 6 | 4,453 | $0.0020 | 1.6 s | +| math-swarm | lattice-5x8 | correct, via synthesis, 13 rounds | 58 | 46,104 | $0.0186 | 14.5 s | +| math-swarm | digitsum-2pow50 | correct | 55 | not recorded | $0.0231 | not recorded | + +```sh +OPENROUTER_API_KEY=... cargo run --release -p tinyhivemind-lang --example hive_view -- \ + crates/tinyhivemind-lang/examples/hives/math-swarm --run --live --seed 1 \ + --problem lattice-5x8 --max-calls 300 +``` + +Artifacts (every request and response, `calls.ndjson`, `frames.ndjson`, +`usage.json`) land in `hive-runs/hive-view/-seed-/` at the main +checkout's root, or under `$HIVE_RUNS_DIR`. Three single-seed episodes show +the routed pipeline working end to end with a real model; they are not an +accuracy measurement. diff --git a/docs/experiments/README.md b/docs/experiments/README.md index 28c7201e..fdc69e98 100644 --- a/docs/experiments/README.md +++ b/docs/experiments/README.md @@ -25,6 +25,8 @@ does. | date | question | answer | | --- | --- | --- | +| [2026-10-11](2026-10-11-euler-hive-live.md) | Can a hive defined entirely in the hive language attempt live Euler problems with web search, sandboxed Python and CortexDB memory? | Yes for the design problem: correct on both full runs, about $0.40 each. The harder equilibrium problem reproduced every sample but never reached the right sum: two runs committed wrong answers. The runs exposed host defects, now fixed and tested. With the fixes in place, verify objected to the wrong value and the run ended `no_decision`. | +| [2026-10-11](2026-10-11-math-hive-scale.md) | Offline, does a math hive decide better at one, five, or fifty seats? | Swarm of 50 was right on 50/50 seeds, one seat on 64%, and the five-seat panel on 58%: 28% of panel runs exhausted their budget. The swarm also has exact residue seats the panel lacks, so size alone is not isolated | | [2026-10-10](2026-10-10-live-hive-language.md) | Can a live model propose a guarded hive edit and evaluate the lowered candidate? | Three repeated smoke runs accepted a 3/3 correction and rejected a 0/3 negative control; lineage and immutable-setting guards held. | | [2026-09-22](2026-09-22-live-through-the-crate.md) | Do the crate, the driver and the ledger hold under a real model and router? | Yes. Four runs: the first hunted for a diff, the second pooled all four private facts, the third reached quiescence with queue, hold and a five-hop handoff chain, the fourth ran real conversations and lost its handoffs to a refused in-thread broadcast, the fifth joined chain and conversations and found that every refusal inside a thread breeds a phantom call, the sixth dropped `post` and produced both deliverables and all four facts, the seventh stalled on two seats that typed their handoff deliverables instead of recording them, the eighth -- told how to record them -- reached quiescence with both deliverables in nine turns, the ninth ran a second desk built to fire the budget, the broadcast that completes its author, and the in-thread `ask` refusal -- the first two fired, and the tenth fired all three and reached quiescence with every private fact on the desk | | [2026-09-22](2026-09-22-live-through-the-raw-runner.md) | Do the driver, the ledger and the record hold with no MCP server -- raw sessions with the tools on their belt? | Yes, on the first attempt once the runner booted the core as a library host: eleven turns, three conversations, every handoff placed by routing, both deliverables on the desk, every refusal in the record's words; the one thing found was that a raw session with no ambient core context is treated as the desktop and waits on a sign-in | @@ -66,6 +68,6 @@ a measurement: it establishes nothing about folding and two things about how a turn can fail without anybody noticing. The harnesses that produce these numbers are documented in -[`../../crates/tinyhivemind-hive/examples/bench/README.md`](../../crates/tinyhivemind-hive/examples/bench/README.md) -; the historical `crosstalk` harness has been retired. The headline benchmark report lives on the wiki's +[`../../crates/tinyhivemind-core/examples/bench/README.md`](../../crates/tinyhivemind-core/examples/bench/README.md); +the historical `crosstalk` harness has been retired. The headline benchmark report lives on the wiki's [Benchmarks](https://github.com/tinyhumansai/tinyhivemind/wiki/Benchmarks) page. diff --git a/docs/plans/README.md b/docs/plans/README.md index 3462de30..01252452 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -41,7 +41,7 @@ See [`example-retry-policy.md`](example-retry-policy.md) for a test-first sample - [`responders.md`](responders.md) — P6 responder ladder and selector port. - [`mention-dispatch.md`](mention-dispatch.md) — P7 bounded dispatch decision and atomic enqueue port. -- [`hive-mind.md`](hive-mind.md) — P8 the `tinyhivemind-hive` crate and its +- [`hive-mind.md`](hive-mind.md) — P8 the deliberation crate (now core's `hive` module) and its deliberation episode. - [`off-floor-exchange.md`](off-floor-exchange.md) — private exchange that takes no floor, bounded by a host-set budget in model calls. diff --git a/docs/plans/a-real-provider-layer.md b/docs/plans/a-real-provider-layer.md index 863db1f7..dece4e7d 100644 --- a/docs/plans/a-real-provider-layer.md +++ b/docs/plans/a-real-provider-layer.md @@ -1,5 +1,11 @@ # Back the desk example with tinyinference and tinytools +> **Path note.** Paths and commands below are updated to the consolidated +> `crates/tinyhivemind-core` layout; this plan originally targeted the former +> `crates/tinyhivemind` runtime crate and its since-retired `desk` example ([ADR +> 0027](../adr/0027-retire-unused-adapters-and-optional-mechanisms.md)); paths +> into that example are kept as written. + **Status:** Landed. `anyhow` came with `tinytools` — implementing its `Tool` means naming the error type it returns — and `--tool-surface` was added so the `tinytools` rendering has a caller in this host rather than only in its tests. @@ -31,7 +37,7 @@ library crate gains a dependency and `assert-pure.sh` is unchanged. 1. Add the two git dev-dependencies to the root `[workspace.dependencies]`, pinned by `rev`, each with the comment the repository's dependency guidance asks for: what it is, and which example uses it. Take them in - `crates/tinyhivemind/Cargo.toml` under `[dev-dependencies]`. Then run + the former runtime crate's `Cargo.toml` under `[dev-dependencies]`. Then run `.github/scripts/assert-pure.sh` and confirm it still reports clean — that run is the test for this task, and it is what proves the boundary held. @@ -68,7 +74,7 @@ library crate gains a dependency and `assert-pure.sh` is unchanged. `commit_utterance`. This is the `tinytools` path's half of criterion 3 of [`../specs/the-utterance-surface.md`](../specs/the-utterance-surface.md). -7. Update `examples/desk/README.md` and `crates/tinyhivemind/examples/README.md` +7. Update `examples/desk/README.md` and the former runtime crate's `examples/README.md` to say what each crate is for and that neither is a library dependency, and link ADR 0013. @@ -80,7 +86,7 @@ cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features cargo test --all-features .github/scripts/assert-pure.sh -tree="$(cargo tree -p tinyhivemind -e normal,build)" || exit 1 +tree="$(cargo tree -p tinyhivemind-core -e normal,build)" || exit 1 if printf '%s\n' "$tree" | grep -Eiq 'reqwest|anyhow|tokio'; then echo "unexpected library dependency" >&2 exit 1 diff --git a/docs/plans/fold-by-size.md b/docs/plans/fold-by-size.md index 2898f59e..fe1cc5eb 100644 --- a/docs/plans/fold-by-size.md +++ b/docs/plans/fold-by-size.md @@ -1,5 +1,9 @@ # Fold the room by size, and give a joining seat the account +> **Path note.** Paths and commands below are updated to the consolidated +> `crates/tinyhivemind-core` layout; this plan originally targeted the former +> `crates/tinyhivemind` runtime crate. + **Status:** Landed. Task 2's audience filter lives in the host's character count rather than in the library, because the count itself is host state; the pinned-message obligation the summarizer now carries was added to the spec @@ -18,7 +22,7 @@ No new port, no tokenizer, no change to what a fold may cover. ## Test-first tasks -1. Add failing tests in `crates/tinyhivemind/src/digest/test.rs` for the size +1. Add failing tests in `crates/tinyhivemind-core/src/runtime/digest/test.rs` for the size trigger before the field exists: a channel of 24 rows under `keep_live: 30, fold_after: 20` that today plans `Current` and must plan `Fold` once its desk-visible content exceeds `fold_after_chars`; a channel of 40 short rows @@ -48,12 +52,12 @@ No new port, no tokenizer, no change to what a fold may cover. safety net working as intended, not a fixture regression. 5. Add a failing test that a seat with no prior turns is composed a prompt - containing the account, in `crates/tinyhivemind/tests/`, using a stub + containing the account, in `crates/tinyhivemind-core/tests/`, using a stub digester. This is acceptance criterion 4 and is the one the example currently satisfies by construction rather than by contract. 6. Update the doc comment on `DigestPolicy`, the `digest` module docs, and - `crates/tinyhivemind/src/digest/README.md` to state the character proxy and + `crates/tinyhivemind-core/src/runtime/digest/README.md` to state the character proxy and its ratio in one place, and note that the trigger measures message content and not the composed prompt. diff --git a/docs/plans/hive-mind.md b/docs/plans/hive-mind.md index 3c24999b..b0606488 100644 --- a/docs/plans/hive-mind.md +++ b/docs/plans/hive-mind.md @@ -1,5 +1,9 @@ # Implement the hive mind +> **Path note.** Paths and commands below are updated to the consolidated +> `crates/tinyhivemind-core` layout; this plan originally targeted a separate +> `tinyhivemind-hive` crate, since folded into core's `hive` module. + Linked specification: [`../specs/hive-mind.md`](../specs/hive-mind.md) ## Goal @@ -14,7 +18,7 @@ a journal, an async dependency, or fan-out. [`../adr/0002-hive-episodes-are-sequential.md`](../adr/0002-hive-episodes-are-sequential.md), which fixes the sequential-episode decision and the no-new-port claim before any code is written. -2. Create `crates/tinyhivemind-hive/` with its manifest, `lib.rs` and +2. Create the deliberation crate (now `crates/tinyhivemind-core/src/hive/`) with its manifest, `lib.rs` and `error/mod.rs`; wire it into `[workspace.dependencies]` and add it to `pure_crates` in `.github/scripts/assert-pure.sh`. Close that script's own long-standing gap by giving `tinyhivemind` the narrower exempt list its @@ -44,7 +48,7 @@ cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features cargo test --all-features -cargo run -p tinyhivemind-hive --example hive +cargo run -p tinyhivemind-core --example hive .github/scripts/assert-pure.sh RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features cargo test --doc diff --git a/docs/plans/jev-first-routing.md b/docs/plans/jev-first-routing.md index 20b15f26..45da5a8f 100644 --- a/docs/plans/jev-first-routing.md +++ b/docs/plans/jev-first-routing.md @@ -1,5 +1,10 @@ # Implement Jev-first routing +> **Path note.** Paths and commands below are updated to the consolidated +> `crates/tinyhivemind-core` layout; this plan originally targeted separate +> `tinyhivemind-embed` and `tinyhivemind-typesafe` crates, since folded into +> core's `embed` and `typesafe` modules. + **Status:** Implemented locally **Specification:** [`../specs/jev-first-routing.md`](../specs/jev-first-routing.md) @@ -13,17 +18,17 @@ hive without changing core/hive purity or adopting host types. 1. Add conversation wire tests, then define `ConversationKind`, `ConversationRef`, and distinct outbound message routes in - `crates/tinyhivemind-embed/src/conversation.rs`. + `crates/tinyhivemind-core/src/embed/conversation.rs`. 2. Add eligibility, fallback, DM-bypass, escalation, and invitation tests in - `crates/tinyhivemind-embed/src/routing/test.rs`; implement the stable routing + `crates/tinyhivemind-core/src/embed/routing/test.rs`; implement the stable routing payloads and pure acceptance/composition beside them. 3. Pin current System One request/response JSON in - `crates/tinyhivemind-typesafe/src/test.rs`; implement exact wire types and + `crates/tinyhivemind-core/src/typesafe/test.rs`; implement exact wire types and the executor-neutral `SystemOneTransport` in `wire.rs`. 4. Test one-call ordinary routing and bounded large-desk hierarchy; implement `JevRouter` question construction and fixed-point conversion in `router.rs`. 5. Add the 1,000-agent/100-desk mixed-surface proof in - `crates/tinyhivemind-embed/tests/routing_scale.rs`. + `crates/tinyhivemind-core/tests/migrated_embed/routing_scale.rs`. 6. Add the conversation, routing, and OpenCompany compatibility specs plus ADRs 0017 and 0018. Update crate/source indexes and the purity guard. 7. Run formatting, strict Clippy, build, tests, purity, rustdoc, doctests, and diff --git a/docs/plans/jev-integration.md b/docs/plans/jev-integration.md index 3f288f3c..4e4936d0 100644 --- a/docs/plans/jev-integration.md +++ b/docs/plans/jev-integration.md @@ -1,5 +1,9 @@ # Implement typed System One decisions +> **Path note.** Paths and commands below are updated to the consolidated +> `crates/tinyhivemind-core` layout; this plan originally targeted the former +> `crates/tinyhivemind` and `tinyhivemind-hive` crates. + Linked specification: [`../specs/jev-integration.md`](../specs/jev-integration.md). ## Goal and constraints @@ -15,7 +19,8 @@ remains the deterministic compatibility path. 1. **Approval algebra and runtime gate — complete.** - Add failure and wire tests in `crates/tinyhivemind-core/src/approval/test.rs` and - `crates/tinyhivemind/src/approval/test.rs`. + the former runtime crate's approval gate tests (the gate was later + retired). - Implement the total fold under `tinyhivemind-core/src/approval/` and the async host port under `tinyhivemind/src/approval/`; export them from each crate root and align their module READMEs. @@ -28,7 +33,7 @@ remains the deterministic compatibility path. 3. **Probability-weighted quorum — complete.** - Add source-binding, admission, freshness, duplicate, composition, public - API, and episode-wire tests under `tinyhivemind-hive/src/quorum/test/` and + API, and episode-wire tests under `crates/tinyhivemind-core/src/hive/quorum/test/` and the hive integration suites. - Implement `DecisionEvaluation`, `standings_with_evaluations`, and `step_with_evaluations` in the hive quorum and episode modules without @@ -36,7 +41,7 @@ remains the deterministic compatibility path. 4. **Native Jev adapter — complete.** - Pin `tinyjevclient` as an example-only dev dependency in `Cargo.toml` and - `crates/tinyhivemind-hive/Cargo.toml`; keep normal/build trees pure. + `crates/tinyhivemind-core/Cargo.toml`; keep normal/build trees pure. - Add routing Choice, stance Choice, evidence Score, violation Noul, label validation, and approval narrowing in `examples/bench/jev.rs`, with tests. @@ -63,7 +68,7 @@ cargo test --all-features .github/scripts/assert-pure.sh RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features cargo test --doc -cargo run -p tinyhivemind-hive --example bench -- --stats-check +cargo run -p tinyhivemind-core --example bench -- --stats-check ``` ## Completion checklist diff --git a/docs/plans/mention-dispatch.md b/docs/plans/mention-dispatch.md index 91b1ab6e..0d0c529e 100644 --- a/docs/plans/mention-dispatch.md +++ b/docs/plans/mention-dispatch.md @@ -1,5 +1,9 @@ # Implement mention dispatch +> **Path note.** Paths and commands below are updated to the consolidated +> `crates/tinyhivemind-core` layout; this plan originally targeted the former +> `crates/tinyhivemind` runtime crate. + Linked specification: [`../specs/mention-dispatch.md`](../specs/mention-dispatch.md) ## Goal @@ -15,7 +19,7 @@ port without adding persistence, retries, fan-out, or host types. 2. Add failing runtime port tests for no-call, one-call, refusal, failure, bound scope, and concurrent idempotency; implement `MentionTurnQueue` and `dispatch_mention` in - `crates/tinyhivemind/src/dispatch/`. + `crates/tinyhivemind-core/src/dispatch/` (the queue wrapper was later retired). 3. Add typed runtime enqueue failure, public exports and crate documentation. 4. Link the specification and plan, then mark P7 complete with host integration and live verification as the next work. diff --git a/docs/plans/off-floor-exchange.md b/docs/plans/off-floor-exchange.md index 257d4e7c..2c66fd94 100644 --- a/docs/plans/off-floor-exchange.md +++ b/docs/plans/off-floor-exchange.md @@ -1,5 +1,9 @@ # Implement off-floor exchange +> **Path note.** Paths and commands below are updated to the consolidated +> `crates/tinyhivemind-core` layout; this plan originally targeted a separate +> `tinyhivemind-hive` crate, since folded into core's `hive` module. + Linked specification: [`../specs/off-floor-exchange.md`](../specs/off-floor-exchange.md) ## Goal @@ -17,13 +21,13 @@ No new port, no waiting, no second journal, and no change to `step`. 1. Add failing invariants for the property the whole design rests on, *before* the mechanism exists: `step` returns the same `HiveStep`, including the state it commits, for a transcript with and without private rows interleaved - anywhere. One readable case in `crates/tinyhivemind-hive/src/episode/test.rs` + anywhere. One readable case in `crates/tinyhivemind-core/src/hive/episode/test/` and one *addition* invariant over arbitrary transcripts in - `crates/tinyhivemind-hive/tests/fuzz_invariants.rs`, whose corpus carries the + `crates/tinyhivemind-core/tests/migrated_hive/fuzz_invariants.rs`, whose corpus carries the same fuzzed grammar in its private rows as in its desk rows. Confirm both go red when the audience filter is removed from `live_traces`. 2. Add failing unit tests for the fold's decisions, then implement - `crates/tinyhivemind-hive/src/exchange/`: `ExchangePolicy` off by default, + `crates/tinyhivemind-core/src/hive/exchange/`: `ExchangePolicy` off by default, `ExchangeRound`, `NoExchangeReason`, and `exchange`. Cover the default opening no round, a zero cap closing rather than erroring, spend read back out of the transcript, a spent member dropping out, each ceiling binding @@ -38,7 +42,7 @@ No new port, no waiting, no second journal, and no change to `step`. `round_cap × max_active_members`, closes with the reason the binding cap implies, and leaves `step` unchanged. 5. Export `ExchangePolicy`, `ExchangeRound`, `NoExchangeReason` and `exchange` - from `crates/tinyhivemind-hive/src/lib.rs`, and add the module `README.md`. + from `crates/tinyhivemind-core/src/hive/mod.rs`, and add the module `README.md`. 6. Wire the harness: `AsideMode::OffFloor`, `Participant::exchange`, one round between turns, and `--exchange-cap` as a knob separate from `--aside-cap` because it bounds model calls rather than the room's turns. @@ -64,14 +68,14 @@ cargo build --all-targets --all-features cargo test --all-features .github/scripts/assert-pure.sh RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features -cargo run --release -p tinyhivemind-hive --example bench -- --stats-check +cargo run --release -p tinyhivemind-core --example bench -- --stats-check ``` Plus the identity check, which is what makes every comparison a difference in one thing rather than in two: ```sh -cargo run --release -p tinyhivemind-hive --example bench -- \ +cargo run --release -p tinyhivemind-core --example bench -- \ --hidden-profile --blind-evidence --budget 40 --episodes 500 \ --aside-cap 0 --exchange-cap 0 ``` diff --git a/docs/plans/promote-the-utterance-surface.md b/docs/plans/promote-the-utterance-surface.md index 5cffe920..6f37665e 100644 --- a/docs/plans/promote-the-utterance-surface.md +++ b/docs/plans/promote-the-utterance-surface.md @@ -1,5 +1,9 @@ # Promote the utterance surface into the library +> **Path note.** Paths and commands below are updated to the consolidated +> `crates/tinyhivemind-core` layout; this plan originally targeted the former +> `crates/tinyhivemind` runtime crate and its since-retired `desk` example. + **Status:** Landed. Linked specification: [`../specs/the-utterance-surface.md`](../specs/the-utterance-surface.md) @@ -7,7 +11,7 @@ Linked specification: [`../specs/the-utterance-surface.md`](../specs/the-utteran ## Goal Move the algebra of *what a seat says and what that becomes* out of -`crates/tinyhivemind/examples/desk` and into `crates/tinyhivemind::speech`, so a +the `desk` example (since retired) and into `tinyhivemind_core::runtime::speech`, so a host takes it as a dependency rather than reimplementing it — and close the two defects the move exposes: a policy refusal that never reaches its author, and a recipient list that is turned back into prose to be re-parsed. @@ -18,8 +22,8 @@ one-message-one-turn. ## Test-first tasks 1. Add failing tests for the utterance wire form and its rejections in - `crates/tinyhivemind/src/speech/test/parse.rs`, then implement - `crates/tinyhivemind/src/speech/`: `Utterance`, `UtteranceRejection`, and + `crates/tinyhivemind-core/src/runtime/speech/test/parse.rs`, then implement + `crates/tinyhivemind-core/src/runtime/speech/`: `Utterance`, `UtteranceRejection`, and `parse_utterance`. Port the cases the example already covers in `examples/desk/mcp/test.rs` — a blank message, a `dm` with an empty `to`, a `to` entry written `@lead`, an unknown kind, a garbled line — and add the @@ -34,7 +38,7 @@ one-message-one-turn. the test in the example assert equality rather than restating them. 3. Add failing tests for `commit_utterance` in - `crates/tinyhivemind/src/speech/test/commit.rs` covering: a `Post` becoming + `crates/tinyhivemind-core/src/runtime/speech/test/commit.rs` covering: a `Post` becoming one desk row with its mentions resolved; a `Dm` becoming an aside whose audience came from the `to` field and not from the message body; a `Dm` the aside policy declines becoming a desk row **with** a populated @@ -68,7 +72,7 @@ one-message-one-turn. 8. Export `Utterance`, `UtteranceRejection`, `ToolSpec`, `tool_specs`, `CommitRequest`, `CommittedUtterance` and `commit_utterance` from - `crates/tinyhivemind/src/lib.rs`, and give `speech/` a `README.md` covering + `crates/tinyhivemind-core/src/runtime/mod.rs`, and give `speech/` a `README.md` covering the split between what the library decides and what the host serves. 9. Add an offline end-to-end regression: run the desk example's fold over a diff --git a/docs/plans/sessions.md b/docs/plans/sessions.md index 8e248d36..2884bccb 100644 --- a/docs/plans/sessions.md +++ b/docs/plans/sessions.md @@ -1,5 +1,9 @@ # Implement attributed session projection and initialization +> **Path note.** Paths and commands below are updated to the consolidated +> `crates/tinyhivemind-core` layout; this plan originally targeted the former +> `crates/tinyhivemind` runtime crate. + Implements [`../specs/sessions.md`](../specs/sessions.md). ## Goal and boundaries @@ -10,7 +14,7 @@ watermarks, response selection, mention dispatch, storage, or a runtime choice. ## Test-first tasks -1. Create `crates/tinyhivemind/src/session/types.rs` and pin the serde forms of +1. Create `crates/tinyhivemind-core/src/runtime/session/types.rs` and pin the serde forms of sequence, conversation, authors, raw rows, pages, projected messages, and queries in `session/test.rs` before implementing the records. 2. Add the object-safe `SessionLog` port and compile a trait-object test before diff --git a/docs/research/biology.md b/docs/research/biology.md index 4b654756..122d549a 100644 --- a/docs/research/biology.md +++ b/docs/research/biology.md @@ -25,7 +25,7 @@ constructive function. The distinction is load-bearing. A sematectonic trace is self-validating, because the artifact is the evidence; a marker is cheap, and can be stale or wrong. -`tinyhivemind-hive` implements sign-based stigmergy only. A `!support #topic` +The `hive` module of `tinyhivemind-core` implements sign-based stigmergy only. A `!support #topic` is a marker. Nothing in the trace grammar points at the state of the work, and the [live hidden-profile experiment](../experiments/2026-09-01-live-hidden-profile.md) records exactly the failure that predicts: markers accumulated on a decoy while @@ -327,7 +327,7 @@ is `p = 1`. Real rooms share a brief. what everyone already knows and fail to surface uniquely-held information, so a group can systematically choose worse than its own members' pooled information would support. This is the literature for what -[`checkout-503`](../../crates/tinyhivemind-hive/examples/bench/scenarios) is, +[`checkout-503`](../../crates/tinyhivemind-core/examples/bench/scenarios) is, and it should be named on the wiki. **The superorganism, honestly.** Boomsma & Gawne, *Biological Reviews* 93:28–54 @@ -352,7 +352,7 @@ Five of these are one equation under different names. So the minimal substrate is: a **conserved pool** of attention, a **quality-weighted deposit**, a **decay**, a **bilinear inhibition**, and a -**nonlinear threshold**. `tinyhivemind-hive` has decay, inhibition, and a +**nonlinear threshold**. The `hive` module has decay, inhibition, and a threshold. It has no conserved pool, and its deposits are weighted by kind rather than by quality — which is the same sentence, twice, as the two ADRs this research produced. diff --git a/docs/research/grok-bots/catalogs-and-bloks.md b/docs/research/grok-bots/catalogs-and-bloks.md index 1fb9981e..eadb8b99 100644 --- a/docs/research/grok-bots/catalogs-and-bloks.md +++ b/docs/research/grok-bots/catalogs-and-bloks.md @@ -133,7 +133,7 @@ further reading, not verified findings. | `Nesqual-Tech/nesqbot` | Handover ledgers plus human gates before consequential actions — a trust-transfer framing of the handoff-artifact pattern, distinct from a plain kanban. | | `pmcclelland/bot-board` | Shared kanban with one MCP surface for humans and Bots together — externalizes the ticket/handoff pattern as shared infrastructure rather than in-memory state. | | `Archive228/foreman` | Crew harness with an explicit *effect ledger* and shift reports against declared work — closest catalog analog to an audit trail over agent actions. | -| `granda/botops` | CoS → L2 → L3 layered fleet lifecycle with stated "quiet swarm" rules — a named hierarchy-depth convention worth comparing against `tinyhivemind-hive`'s cross-inhibition/quorum design. | +| `granda/botops` | CoS → L2 → L3 layered fleet lifecycle with stated "quiet swarm" rules — a named hierarchy-depth convention worth comparing against the `hive` module's cross-inhibition/quorum design. | | `RishabhPatel123/Rixy-Bot` | Push (not poll) approval queues plus swarms in a from-scratch Android multi-agent framework — a second independent implementation of the approval-queue-as-first-class-surface pattern. | | `mandarwagh9/botroster` | Named teammates, one durable computer, approvals and routines, built explicitly as an open alternative — a compact reference for what "roster" + "desk" means to another implementer. | diff --git a/docs/research/grok-bots/gojiberryai-sales-os.md b/docs/research/grok-bots/gojiberryai-sales-os.md index 0bc05239..ab00802c 100644 --- a/docs/research/grok-bots/gojiberryai-sales-os.md +++ b/docs/research/grok-bots/gojiberryai-sales-os.md @@ -206,7 +206,7 @@ and an approval gate that is a compliance convention repeated at every layer because the authors evidently don't trust it to hold once. Nothing here folds over a transcript; nothing is order-independent, decays, or accumulates a score across turns the way `standings`, `salience`, or `directory` do in -`crates/tinyhivemind-hive`. The "thirteen agents" figure is really thirteen +`crates/tinyhivemind-core/src/hive/`. The "thirteen agents" figure is really thirteen context windows sharing one static routing table — closer to a shell script with LLM-shaped steps than to a deliberation protocol. diff --git a/docs/research/grok-bots/orange-book.md b/docs/research/grok-bots/orange-book.md index 54f1dcf8..5ecdb047 100644 --- a/docs/research/grok-bots/orange-book.md +++ b/docs/research/grok-bots/orange-book.md @@ -336,7 +336,7 @@ cost spread — illustrating why routine-ification is framed as the top lever. four-stage handoff, generalized to N stages with the chat message (rather than a formal ticket) as the connective tissue — a looser, less traceable handoff than pattern 3's ticket-based one. Type 1 (sentinel) is a -trigger/condition/escalate loop, matching `tinyhivemind-hive`'s "silent +trigger/condition/escalate loop, matching the `hive` module's "silent unless salience crosses a threshold" shape more than a scheduled cron. **Worked pipeline example, step by step (operational, but concrete):** (1) @@ -418,6 +418,6 @@ because they bound how much of this to trust as mechanism):** | Routine (skill-by-demonstration, replay on cue) | Nothing analogous — no "teach once, replay" concept in scope | Entirely out of scope: `tinyhivemind` holds no state and does no learning; a routine is host-owned automation, not hive-mind mechanics | | Auto-review three-bucket approval trigger (payment / sensitive / low-confidence) | Nothing analogous in the pure crates | No approval-gate port; this is squarely a host concern per the "host owns storage" rule, but the *predicate shape* (a decidable classification over an action) is a plausible future port | | Six quota sinks, esp. poll→event-driven and session-boundary-as-topic-boundary | `continuous-sharing.md` (caller-owned watermarks, stateless deltas) already avoids re-paying for full history per read | No automatic "topic changed, start fresh" heuristic — session boundaries are caller-decided, not inferred | -| Unattended sentinel type (silent-until-threshold escalation) | `tinyhivemind-hive`'s decaying salience + response thresholds is structurally the closest analog (a fold that stays silent below a threshold and escalates above it) | No wall-clock scheduling — hive is a pure fold over caller-supplied state, deliberately holds no timer | +| Unattended sentinel type (silent-until-threshold escalation) | The `hive` module's decaying salience + response thresholds is structurally the closest analog (a fold that stays silent below a threshold and escalates above it) | No wall-clock scheduling — hive is a pure fold over caller-supplied state, deliberately holds no timer | | Unattended pipeline type (N-stage chain via chat handoff) | Sequential episode structure in `hive-mind.md`/ADR-0002 (one turn at a time, no concurrent variants) | No named "pipeline" abstraction chaining desks; would be host-composed from repeated referrals/dispatches | | One-message-can-start-many-turns risk (group chat "no one taught it to do this") | Explicit invariant: "@everyone is a list, not a broadcast" — one message starts at most one turn | The orange book's own examples (spontaneous group messaging) show this invariant being *violated* in the wild by Grok Bot — useful negative evidence for why the invariant matters | diff --git a/docs/research/grok-bots/rakazo.md b/docs/research/grok-bots/rakazo.md index af0710a2..d6c4849c 100644 --- a/docs/research/grok-bots/rakazo.md +++ b/docs/research/grok-bots/rakazo.md @@ -338,5 +338,5 @@ rule in `AGENTS.md:4` only. | Semantic recall fenced by a compaction generation counter | — | Generation-fenced retrieval | | Roster/directory rendering for the prompt (`renderBotDirectory`) | Yes — roster records | — | | Group handoff refusing to return a stage to its sender | Partly — cross-desk referral is one bounded child turn | An explicit "do not hand back" edge rule | -| Deliberation: traces, salience, quorum, cross-inhibition, attention market | Yes — `tinyhivemind-hive` | — (Rakazo has no deliberation mechanism at all; a group is N independent bots) | +| Deliberation: traces, salience, quorum, cross-inhibition, attention market | Yes — core's `hive` module | — (Rakazo has no deliberation mechanism at all; a group is N independent bots) | | Enforced purity boundary in CI | Yes — `assert-pure.sh` | — (Rakazo's `packages/core` has leaked `dotenv`, `croner`, plotting) | diff --git a/docs/research/grok-bots/reconstructed-018.md b/docs/research/grok-bots/reconstructed-018.md index 26932ecf..fb7575aa 100644 --- a/docs/research/grok-bots/reconstructed-018.md +++ b/docs/research/grok-bots/reconstructed-018.md @@ -482,7 +482,7 @@ added features are real, IO-performing implementations, not stubs. | Mention grammar `@name` with three derived handles per member (`group-chat.ts:7`) | `docs/specs/mentions.md` — roster records, grammar, normalization | Deriving handles from a display name rather than storing one; the ASCII-only word boundary | | `@everyone` / `@all` regex → all members (`group-chat.ts:9`) | `@everyone` as a list, one message one turn | Their default: **no mention also means everyone** | | `resolveResponders` over history since last user message | Responder ladder, `docs/specs/responders.md` | Unioning mentions across *every* message since the last user turn, not just the triggering one | -| Bounded episode: 3 rounds × 10 member turns × 2 messages, ends on a silent round (`group-chat-orchestrator.ts:45`) | `tinyhivemind-hive` episode state machine, sequential by ADR 0002 | Their caps are plain constants, not a policy record; the "silent round ends it" stop rule | +| Bounded episode: 3 rounds × 10 member turns × 2 messages, ends on a silent round (`group-chat-orchestrator.ts:45`) | the `hive` module's episode state machine, sequential by ADR 0002 (since superseded by ADR 0014) | Their caps are plain constants, not a policy record; the "silent round ends it" stop rule | | `"(pass)"` convention + `isPotentialPassPrefix` streaming suppression | Response thresholds, cross-inhibition | An explicit *textual* pass token the model emits, and prefix-suppression while it streams | | `orderRoundSpeakers` rotation; `messagesSinceMemberLastSpoke` + `formatGroupHistory(limit=24)` | One-responder selection; projection fold, `docs/specs/sessions.md` | Rotating the opener by round number; slicing from the viewer's own last utterance as the whole prompt input | | `seq` + stable `id` on one table, ids position-derived by `nextEntryId` (`agent-db-schema.ts:15`, `transcript-entry-ids.ts:5`) | Sequence-number addressing across host surfaces | Nothing on `seq` — that agrees. New: deriving the string id from a fold over the transcript, so it is reproducible | diff --git a/docs/specs/README.md b/docs/specs/README.md index 8e60beb7..137a8469 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -153,3 +153,4 @@ rather than restating it. Two run across several specifications: - [`working-memory.md`](working-memory.md) — the port seats carry observations across activations through, with the engine left to the host ([ADR 0029](../adr/0029-working-memory-is-a-host-adapter.md)). - [`hive-language.md`](hive-language.md) — pure declarative hive packages, guarded edits, memory bindings, and evaluated lineage. +- [`hive-language-checking.md`](hive-language-checking.md) — direct file edits checked like a compiler: located diagnostics, collected findings, and per-class changes for lineage. diff --git a/docs/specs/approval.md b/docs/specs/approval.md index 7b6626f3..fab23b18 100644 --- a/docs/specs/approval.md +++ b/docs/specs/approval.md @@ -4,7 +4,9 @@ - **Status:** Implemented -- **Owner:** `crates/tinyhivemind-core`, with one port in `crates/tinyhivemind` +- **Owner:** `crates/tinyhivemind-core` (`src/approval/`). The waiting port this + spec placed in the former `crates/tinyhivemind` runtime crate was retired by + ADR 0027. - **Reading:** [`../research/grok-bots/README.md`](../research/grok-bots/README.md) - **Decisions:** [ADR 0008](../adr/0008-an-approval-decision-is-total.md), [ADR 0009](../adr/0009-a-refusal-renders-what-the-caller-already-holds.md) @@ -342,7 +344,8 @@ lets a permissive rule placed first hide a restrictive one placed later. `approve` decides. Nothing in this crate waits for a person, because waiting is IO and this crate does none. -The waiting belongs behind **one new port in `crates/tinyhivemind`**, sibling +The waiting belongs behind **one new port in the runtime layer** (then +`crates/tinyhivemind`, now core's `runtime` module; the port was later retired), sibling to `MentionTurnQueue` and `ReferralQueue`, not in the host directly: ```rust @@ -446,7 +449,8 @@ that table and those tests when it lands. - **No clock, no storage, no host type, no callback, no async, no new dependency, in `crates/tinyhivemind-core::approve`.** `now` and `epoch` are arguments; grants, refusals and audit rows are host-owned. The necessarily - async `ApprovalGate` port lives in `crates/tinyhivemind` instead, per the + async `ApprovalGate` port lived in the former `crates/tinyhivemind` runtime + crate instead (since retired), per the pure-fold/waiting split in [`AGENTS.md`](../../AGENTS.md). ## Acceptance criteria diff --git a/docs/specs/cross-desk-referral.md b/docs/specs/cross-desk-referral.md index d7d0df6e..1efbe8d2 100644 --- a/docs/specs/cross-desk-referral.md +++ b/docs/specs/cross-desk-referral.md @@ -133,7 +133,8 @@ to the asker, because it was appended to the conversation the asker is reading. ### The runtime edge -`crates/tinyhivemind` adds a `ReferralQueue` port with one method, +The former `crates/tinyhivemind` runtime crate added a `ReferralQueue` port +(since retired; the pure decision now lives in `crates/tinyhivemind-core/src/referral/`) with one method, `enqueue_once(Referral)`, and `dispatch_referral`, mirroring `MentionTurnQueue` and `dispatch_mention` exactly: a pure `None` decision calls the queue zero times, a `One` decision calls it exactly once, expected refusals come back as diff --git a/docs/specs/hive-language-checking.md b/docs/specs/hive-language-checking.md new file mode 100644 index 00000000..dc781781 --- /dev/null +++ b/docs/specs/hive-language-checking.md @@ -0,0 +1,129 @@ +# Checking direct edits to a hive package + +Status: Accepted. Extends [the hive language](hive-language.md). +Decision: [ADR 0033](../adr/0033-editors-write-files-and-the-crate-checks-them.md). + +## Purpose + +An orchestrator or editor agent changes a hive by editing its files, the +way a programmer edits source. It does not author a `Patch` with exact +before/after preconditions. The crate then plays the part of a compiler: it +reads the original files and the edited files, runs every rule the language +and the constitution define, and either reports every problem at once or +returns the classified changes the host needs for lineage. + +The `Patch` wire, `apply`, `guard` and `diff` stay as they are. A patch is now +the *recorded* form of an edit, derived by the checker, not the form an +editor writes. + +## Diagnostics + +A finding is a `Diagnostic` with four fields, all on the wire: + +| Field | Meaning | +| --- | --- | +| `file` | Package-relative path of the file the finding concerns, such as `hive.json`, `memory.json`, `constitution.json` or `seats/solver.md` | +| `pointer` | JSON Pointer inside that file, or `null` for a Markdown file or a whole document | +| `rule` | The rule family, a snake_case `Rule` | +| `message` | Lowercase explanation without trailing punctuation | + +Rules are one per family: `schema`, `identity`, `reference`, `path`, `bound`, +`goal_cycle`, `namespace`, `memory_access`, `context`, `frontmatter`, `json` +and `missing_document` mirror the package `Error` variants; `forbidden_class`, +`immutable`, `contamination`, `read_only`, `unowned`, `original` and +`sequence` are edit rules. A diagnostic displays compiler style: +`hive.json:/seats/0/role: reference: unresolved role reference ghost`, or +`seats/solver.md: frontmatter: ...` when there is no pointer. + +Every validation finding names where it lives. A seat's role and prompt are +`hive.json` at `/seats//role` and `/seats//prompt`; a missing prompt +document is reported at the manifest field that names it. Frontmatter and +context-marker findings name the Markdown file. Memory findings name +`memory.json`, except protected namespaces and read-only pins, which live in +`constitution.json`. Policy bounds name the `hive.json` field (`/policies/...` +or `/coordinator/...`) except a zero constitution cap, which names +`constitution.json`. + +## Collecting rather than stopping + +- `diagnose(&Package) -> Vec` runs every validation rule and + returns every finding. Checks that depend on an earlier one for the same + entry (a frontmatter check on a document that is missing) are skipped. +- `validate` keeps its signature and result: it returns the first finding + as the same `Error` it returned before. The rules run in the original + order, so the first finding is the error the fail-fast code produced. +- `diagnose_documents(&BTreeMap)` returns + `(Option, Vec)`. It reports every unsafe path, a + missing or undecodable canonical file per file (serde's line and column in + the message), an unknown nested field at its pointer, and, once the three + canonical files decode, every validation finding. The package is `Some` + whenever the canonical files decode; it is valid exactly when the list is + empty. `parse` returns the first finding as its error. + +## The check + +`check(original, edited)` takes two document maps; +`check_packages(original, edited)` takes two packages. Both return +`Result>`. + +1. The original must be valid. Each finding against it is reported with rule + `original`, since the editor cannot be blamed for it. +2. The edited files are diagnosed as above. +3. The change is classified. Every manifest or memory top-level field and + every Markdown document that differs is one change, owned by the edit + class whose boundary admits it (the same table `patch` enforces). The + pointer of a field change is its first differing leaf, such as + `/policies/episode/turn_budget`. +4. Each finding below is collected; nothing stops at the first: + - `immutable`: any change to `constitution.json`; any protected key + (`evaluation`, `telemetry`, `logging`, `constitution`, or `acceptance` + outside goals) in the manifest or a frontmatter; any change to an existing + document's frontmatter; a prompt body that opens a second frontmatter. + - `forbidden_class`: a change whose class is not in `permitted_edits`. + - `unowned`: a change no class owns, such as `hive.json:/name`, a + top-level `README.md`, or a file that is not part of the package. + - `contamination`: a constitution canary anywhere in the edited manifest, + memory or documents. + - `read_only`: a change to a pinned memory identity or its bindings. + - `context`: a context document whose edit is not expressible as item + deltas — text before the first item changed, items reordered, a new item + not at the end, or a body without its final newline. + - every validation finding of the edited package. +5. With no findings, the changes are grouped into one `Patch` per touched + class, in class order (`prompt`, `context`, `memory`, `roster`, + `topology`, `policy`, `goal`). If applying them in that order would pass + through an invalid package, the first order that is valid at every step + is used instead. If no order is, one `sequence` diagnostic says so. + +Diagnostics are deduplicated by file, pointer and rule, and sorted by file +then pointer. + +## The review and lineage + +`Review { changes, candidate }` holds the derived patches and the edited +package. Applying `changes` in order with `patch::apply`, starting from the +original, reproduces `candidate` exactly; every intermediate package is +valid. A host records one lineage `Record` per change, chaining parents, so +each step carries its own class and content id and the last id is the edited +package's. A host that evaluates only the whole edit gives every step the +verdict and scores of the whole. + +Memory contents are not files. Learning operations stay on the existing +`Patch` path with `apply`. + +## The command-line check + +`cargo run -p tinyhivemind-lang --example hive_check -- +[--json]` loads two package directories, prints one diagnostic per line (or +a JSON array) and exits non-zero on any finding. With one directory it +diagnoses that package alone. Dot-files are skipped. + +## Acceptance evidence + +Unit tests pin the diagnostic wire, every rule, the first-error equivalence +with `validate`, and multi-finding collection. The `direct_edits` +integration test edits the gallery's `self-improving` package: a prompt +edit is accepted; a policy edit is refused with a pointer; a canary, a +constitution change and a bad reference are reported together; a multi-class +edit's patches reproduce the edited package; unparseable JSON names its file +and line; an unowned change is reported. diff --git a/docs/specs/hive-language.md b/docs/specs/hive-language.md index b5369bf9..0ff2b910 100644 --- a/docs/specs/hive-language.md +++ b/docs/specs/hive-language.md @@ -3,6 +3,7 @@ Status: Accepted for the initial host-neutral format. Implements issue #112. Implementation: [plan](../plans/2026-10-10-hive-language.md). Boundary: [ADR 0032](../adr/0032-hive-language-owns-the-wire-format.md). +Editing: [checking direct edits](hive-language-checking.md), [ADR 0033](../adr/0033-editors-write-files-and-the-crate-checks-them.md). ## Purpose and ownership @@ -11,8 +12,11 @@ in-memory map of relative paths to strings; this library opens no files, connects no clients, creates no agents, and persists nothing. The host owns credentials, evaluation, activation, memory contents, and lineage storage. Canonical manifests use JSON. Markdown carries prompts and role charters. -The initial frontmatter accepts JSON (a YAML-compatible subset); arbitrary -YAML, TOML and sampled families are outside this version. +Frontmatter is optional. When present it is a JSON object between an opening +`---\n` (the document's first four bytes) and the first `\n---\n`. A seat +prompt or role charter that opens with `---\n` and never closes it, or whose +frontmatter is not a JSON object, is rejected. YAML, TOML and other +frontmatter syntaxes are outside this version. ## Package and wire contract @@ -22,25 +26,66 @@ resolve within the package. `constitution.json` pins hard constraints, turn and width budgets, permitted edit classes, read-only memory identities, protected namespace prefixes, contamination canaries, and evaluation, acceptance and telemetry settings. It is supplied by the host and immutable -to every editor operation. Unknown fields and unsupported schema/version -values fail rather than disappearing during a round trip. The `$schema` value -`https://tinyhumans.ai/schemas/hive/v1` identifies this wire version; parsing -does not fetch that URI. - -`seats/.md` contains structured frontmatter (role, template, nonsecret -config, model, tools, MCP and skills) and a prompt body. Roles have independent -charters in `roles/.md`. Shared `context/*.md` documents use stable item -identities, so deltas add, replace or remove one item instead of rewriting -all accumulated context. Seat/role metadata is canonical in the manifest; -frontmatter, when supplied, must agree with it. Declared seat prompts, role -charters and context references must use their respective package directories, -so document purpose cannot bypass edit-class authority. +to every editor operation. Its `hard_constraints`, `evaluation`, `acceptance` +and `telemetry` are opaque to this crate: it carries them, guards them against +edits, and leaves their meaning and enforcement to the host. Unknown fields +and unsupported schema/version values fail rather than disappearing during a +round trip. The `$schema` value `https://tinyhumans.ai/schemas/hive/v1` +identifies this wire version; parsing does not fetch that URI. + +`seats/.md` contains a prompt body, optionally preceded by frontmatter. +Roles have independent charters in `roles/.md`. Shared `context/*.md` +documents use stable `` identities, so deltas add, replace or +remove one item instead of rewriting all accumulated context. Seat and role +metadata is canonical in the manifest; frontmatter may restate it but must +agree with it. Every key in a seat's frontmatter must be a field of that +manifest seat (`id`, `label`, `role`, `prompt`, `template`, `config`, `model`, +`tools`, `mcp`, `skills`) or one of its memory binding settings (`reads`, +`recall_at`, `budget_chars`, `remember`, and `memory` for the bound identity), +with an equal value. A role's frontmatter may hold only `id` and `charter`. +Declared seat prompts, role charters and context references must use their +respective package directories, so document purpose cannot bypass edit-class +authority. Policies reuse core policy structs. Division and conduct gain serde. The coordinator's options gain serde in the coordinator crate; the pure language owns a host-neutral options representation that converts in the adapter. No dependency from the language to hives or OpenHuman is permitted. +### Parsing a document map + +`parse` takes a `BTreeMap` of relative path to contents: + +- Every key must be a safe relative path (no leading `/`, `\`, control + character, empty, `.` or `..` segment, or `:`), including keys that are then + dropped. +- `hive.json`, `constitution.json` and `memory.json` are required and decoded + into their typed sections. +- Every other key ending in `.md` (any case) is kept in `documents`; every + other file, such as `notes.txt`, is dropped. +- A kept Markdown document no manifest entry references (a `README.md`, say) + is accepted and carried along. It is part of the package, so it changes the + content identity. A patch cannot create, remove or edit it unless it sits + under `seats/`, `roles/` or `context/`. +- Beyond serde's own `deny_unknown_fields`, a second pass compares the + decoded value with its input and rejects unknown keys nested inside reused + core policy values. The one exception is `"responder_mode": "lead"` on a + desk: core omits the lead mode when serializing, so an explicit `lead` is + accepted rather than reported as unknown. + +The parsed package is then validated. + +### The whole-package form + +`Package` also serializes as one JSON object with four fields: `manifest`, +`constitution`, `memory`, and `documents` (path to contents). A host may store +or ship a package this way. `crates/tinyhivemind-openhuman/examples/live_language.rs` +reads one (the `accepted-package.json` that `live_self_edit` writes) with +`serde_json` directly. That path skips `parse`'s Markdown filter and nested +unknown-field pass, so the host must call `validate` (or `lower`, which +validates first) before it uses the package; validation re-checks every +document path. + ## Validation and lowering Reject duplicate/empty identities, unresolved roles, seats, desks, goals, @@ -52,10 +97,25 @@ This is stricter than core, whose default is blind width 4 and revealed width 1: packages must explicitly choose compatible widths. Core defaults are unchanged. Constitution caps apply regardless of edit class. +Routing confidences and thresholds (`minimum_confidence`, +`high_impact_minimum_confidence`, `clarification_threshold`, +`high_impact_threshold`) are integer parts per million: `1000000` is +certainty and `250000` is one in four. A value above `1000000` fails to +decode. + Lowering returns existing `Desk`, role-populated `RouteCandidate`, and core policies, plus host-neutral seat creation/configuration, coordinator settings and memory binding data. Goals, missions, graph links and declared shared context are retained for the host to install alongside those runtime values. +Seat prompts and the role charters used as candidate descriptions are handed +over without their frontmatter. + +Each seat with a memory binding lowers to a `LoweredMemoryBinding` derived +from its identity's namespace. `agent_id` is the text after the last `:` +and `root` is the text before the last `/`, falling back to the spec's `root` +when there is no `/`. So `team:hive/agent:critic` yields agent `critic` under +root `team:hive`. The OpenHuman adapter accepts only agent identities whose +namespace is exactly `/agent:`. Adapter helpers convert these to `CoordinatorOptions`, `ManagementRequest`, and the existing OpenHuman `MemoryBinding`. Applying a candidate is a host decision between turns or sessions; an in-flight turn @@ -84,12 +144,30 @@ the existing HiveMemory default remains run-scoped. ## Edits and archive +Editors edit a package's files directly; [checking direct +edits](hive-language-checking.md) specifies how `check` validates them and +derives the patches below, which are the recorded form of an edit +([ADR 0033](../adr/0033-editors-write-files-and-the-crate-checks-them.md)). + Patch classes are `prompt`, `context`, `memory`, `roster`, `topology`, `policy`, and `goal`. Typed manifest operations and stable Markdown item deltas carry preconditions, so stale proposals fail atomically. JSON Pointer operations must remain within their class. Guarding checks the complete candidate, not just operation names. Constitution, evaluation, acceptance and telemetry are -outside editor write authority. The host must guard every application. +outside editor write authority. So is any `evaluation`, `telemetry`, `logging` +or `constitution` key in the manifest or in frontmatter, and any `acceptance` +key outside a goal. The host must guard every application. + +`diff` computes the patch between two packages for one class at coarse +granularity. For `manifest` and `memory` it emits one JSON Pointer operation +per differing *top-level* field (`/manifest/seats`, `/memory/bindings`), +carrying the whole before and after values rather than a minimal path. A +created or removed document becomes one document operation. A changed +context document becomes one operation per changed item. Any other changed +document becomes one prompt-body operation, and a changed frontmatter is +rejected as immutable. The result is guarded before it is returned, so a diff +spanning two classes fails rather than producing a patch the class cannot +apply. A version record contains a cryptographic content identity, parent, patch, class, rationale, transcript sequence references, finite objective scores, diff --git a/docs/specs/openhuman-hive-config.md b/docs/specs/openhuman-hive-config.md index cd36be83..0613ec3c 100644 --- a/docs/specs/openhuman-hive-config.md +++ b/docs/specs/openhuman-hive-config.md @@ -24,9 +24,16 @@ policy, membership-role and scheduled-origin metadata must reach the existing host-neutral coordinator. They belong in hives rather than OpenHuman-owned algebra. No new crate, runtime, storage journal or ADR is required. -[#112](https://github.com/tinyhumansai/tinyhivemind/issues/112)'s future -`tinyhivemind-lang` layer lowers into this manifest. It must use this wire -contract rather than maintain a second deployment schema. `ProfileRuntime` +[#112](https://github.com/tinyhumansai/tinyhivemind/issues/112)'s +`tinyhivemind-lang` package does **not** lower into this manifest. `HiveConfig` +is a parallel, OpenHuman-specific deployment schema. A language package +lowers to a host-neutral `LoweredHive`, and the adapter's helpers in +`crates/tinyhivemind-openhuman/src/language/mod.rs` carry that to OpenHuman +directly: `coordinator_options` maps `CoordinatorSettings` onto +`CoordinatorOptions`, `memory_binding` maps an agent `LoweredMemoryBinding` +onto the harness `MemoryBinding`, and `management_request` turns a +`LoweredSeat` into a `ManagementRequest::CreateAgent`. Neither format +converts into the other. `ProfileRuntime` (openhuman#7250) is out of scope: it cannot coexist with `Runtime` in one process and represents a future multi-tenant mode. @@ -350,4 +357,5 @@ no unattended repository mutation is installed by the configuration layer. Existing pin-consistency assertions remain required. Open questions: none. The memory choice and additive permission semantics are -settled above; future config-language work consumes this contract. +settled above. The hive language is a separate, host-neutral format; see +[`hive-language.md`](hive-language.md). diff --git a/docs/specs/the-utterance-surface.md b/docs/specs/the-utterance-surface.md index 7f91e8af..4e021b19 100644 --- a/docs/specs/the-utterance-surface.md +++ b/docs/specs/the-utterance-surface.md @@ -3,7 +3,8 @@ > **Current integration note.** The `desk` example and TinyHiveMind MCP adapter mentioned below were retired by [ADR 0027](../adr/0027-retire-unused-adapters-and-optional-mechanisms.md). Native `EpisodeTools` remains the integration path. -**Status:** Implemented in `tinyhivemind::speech`. The `desk` transport +**Status:** Implemented in `tinyhivemind_core::runtime::speech` +(`crates/tinyhivemind-core/src/runtime/speech/`). The `desk` transport example and MCP adapter described below were later retired. **Owner:** tinyhivemind maintainers **Reading:** [`thoughts-and-channels.md`](thoughts-and-channels.md), @@ -20,7 +21,7 @@ right. Where it lives is not. **Every load-bearing decision is in an example.** The tool schemas and their descriptions, the outbox wire form, the drain, and the fold from *what a seat -called* to *what the room holds* are `crates/tinyhivemind/examples/desk/mcp.rs` +called* to *what the room holds* were `crates/tinyhivemind/examples/desk/mcp.rs` (431 lines), the house-rules block in `examples/desk/prompt.rs`, and roughly eighty lines of `examples/desk/run.rs`. A second host cannot depend on any of it. It will reimplement it, and the places it differs are exactly the ones that @@ -157,7 +158,8 @@ does so. It does not require any other host to. ## Acceptance criteria -1. `crates/tinyhivemind/examples/desk/mcp.rs` holds transport only — the +1. `crates/tinyhivemind/examples/desk/mcp.rs` (since retired with the `desk` + example) holds transport only — the JSON-RPC loop, the outbox file, and the CLI config block — and names no tool description, no schema and no validation rule of its own. **Met.** 2. Every tool description a seat reads comes from `speech::tool_specs`, asserted diff --git a/docs/testing.md b/docs/testing.md index b537ff36..c63001b0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -14,8 +14,9 @@ Run the mock and feature tests with the normal workspace suite: cargo test --all-features ``` -Two deterministic fuzz-style integration suites also run as part of that -command. `fuzz_invariants.rs` in the core and hive crates generate hundreds of +Deterministic fuzz-style integration suites also run as part of that +command. `fuzz_invariants.rs`, `hive_fuzz_invariants.rs` and +`driver_fuzz_invariants.rs` under `crates/tinyhivemind-core/tests/` generate hundreds of Unicode- and punctuation-heavy inputs and assert parser bounds, byte offsets, determinism, trace ordering, and quorum idempotence. They are deliberately seeded so a failure is reproducible in CI; coverage-guided fuzzing can replay @@ -54,17 +55,17 @@ placed in the process argument list. ## Hive deliberation suite -`crates/tinyhivemind-hive` carries its own in-memory host under -`tests/support/hive_harness.rs`: it owns the journal, runs the one turn each -step authorizes, appends it, and commits the returned state. `hive_episode.rs` -drives that host through convergence, budget exhaustion, a blind opening round, +The core crate's `hive` module is tested through its own in-memory host under +`crates/tinyhivemind-core/tests/migrated_hive/support/hive_harness.rs`: it owns the journal, runs the one turn each +step authorizes, appends it, and commits the returned state. +`hive_hive_episode.rs` (which includes `migrated_hive/hive_episode.rs`) drives that host through convergence, budget exhaustion, a blind opening round, a tie that only cross-inhibition breaks, and the watermark that stops an episode inheriting the votes of the conversation before it. The bundled example prints a readable episode trace and needs no model: ```sh -cargo run -p tinyhivemind-hive --example hive +cargo run -p tinyhivemind-core --example hive ``` The benchmark's statistics live in an example file, so `cargo test` never runs @@ -75,9 +76,9 @@ exercise the expertise and delegation paths the ordinary `--episodes 25` line never reaches: ```sh -cargo run -p tinyhivemind-hive --example bench -- --specialists 2 --cost-tiers --episodes 25 -cargo run -p tinyhivemind-hive --example bench -- --hidden-profile --episodes 25 -cargo run -p tinyhivemind-hive --example bench -- --stats-check +cargo run -p tinyhivemind-core --example bench -- --specialists 2 --cost-tiers --blind-evidence --episodes 25 +cargo run -p tinyhivemind-core --example bench -- --hidden-profile --blind-evidence --episodes 25 +cargo run -p tinyhivemind-core --example bench -- --stats-check ``` Those three are smoke tests rather than assertions about accuracy: a benchmark @@ -91,7 +92,7 @@ The live episode mirrors the OpenRouter suite above and is gated the same way: TINYHIVEMIND_LIVE_OPENROUTER=1 \ OPENROUTER_API_KEY='' \ OPENROUTER_MODEL='' \ -cargo test -p tinyhivemind-hive --features e2e --test openrouter_hive_live -- --nocapture +cargo test -p tinyhivemind-core --features e2e --test hive_openrouter_hive_live -- --nocapture ``` It asserts **structure, not quality**: that real models emit parseable traces, @@ -100,13 +101,13 @@ budget by one of its four terminal steps, and that attribution survives. It does not assert that the room reached a good answer, and it could not — a matched token-budget baseline would be needed to make any such claim. -The `bench` example (`crates/tinyhivemind-hive/examples/bench/`) carries a +The `bench` example (`crates/tinyhivemind-core/examples/bench/`) carries a second, unasserted live path of its own: `--agent-cmd` drives a real scenario through an agent CLI, and `--api-base` drives the same seats directly over HTTP against an OpenAI- or Anthropic-shaped chat endpoint, both through `curl` with the whole request — including the API key — sent over its stdin rather than as a process argument. Nothing here runs in CI; it is documented -in `crates/tinyhivemind-hive/examples/bench/LIVE.md`, alongside the harness +in `crates/tinyhivemind-core/examples/bench/LIVE.md`, alongside the harness command lines for the CLI and HTTP paths and the environment variables each needs. diff --git a/examples/README.md b/examples/README.md index fcf78e4b..9999ced6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,6 +11,7 @@ repository's dependency-purity check. | Example | Purpose | Run | | --- | --- | --- | | [`hives/`](hives/README.md) | Offline reviewer handoff and one continuing agent session across three hives. | `cargo run --manifest-path examples/hives/Cargo.toml --bin one_hive` | +| [`lab/`](lab/README.md) | Trace sinks, six offline knob binaries driving every public core setting, the SWE hive, and a trace viewer. | `cargo run --manifest-path examples/lab/Cargo.toml --bin memory_hive` | | [`openhuman/`](openhuman/README.md) | Run a basic offline OpenHuman hive, the integration proofs, or the live DeepSWE binaries. | `cargo run --manifest-path examples/openhuman/Cargo.toml --bin basic_hive` | Each directory is its own Cargo workspace. That boundary is load-bearing: diff --git a/examples/lab/.gitignore b/examples/lab/.gitignore index 2742d2c5..10955404 100644 --- a/examples/lab/.gitignore +++ b/examples/lab/.gitignore @@ -3,3 +3,4 @@ # Viewer toolchain output. /viewer/node_modules/ /viewer/dist/ +/viewer/shots/ diff --git a/examples/lab/README.md b/examples/lab/README.md index 7228edff..29313d4a 100644 --- a/examples/lab/README.md +++ b/examples/lab/README.md @@ -7,7 +7,7 @@ pure dependency tree. The root README of the repository describes the library. | Path | Purpose | | --- | --- | | `src/` | shared plumbing (`JsonlSink`, `WallClock`, in-memory log), `bin/` examples, and `swe/`, the SWE hive and baseline | -| `viewer/` | offline viewer for the JSONL traces | +| `viewer/` | offline viewer for the JSONL traces, and a live player for the hive-language gallery | | `harbor/` | Harbor agent wrapper for Terminal-Bench | | `tests/` | mock model server and the offline end-to-end script | | `docker/cortex/` | a local CortexDB server for `swe_hive --memory cortex` | diff --git a/examples/lab/viewer/README.md b/examples/lab/viewer/README.md index 1b5554ac..a341bb44 100644 --- a/examples/lab/viewer/README.md +++ b/examples/lab/viewer/README.md @@ -1,10 +1,14 @@ # Run viewer -A Vite app for the telemetry a hive-lab run emits (`TraceEvent` in +Two tabs over one dev server. **Runs** is a Vite app for the telemetry a hive-lab run emits (`TraceEvent` in `crates/tinyhivemind-core/src/telemetry/types.rs`, stamped with `run`, `seq`, `at_ms`). The dev server reads traces straight from disk, so there is nothing to upload: runs appear in a table, a click loads one, **Compare** loads every arm of a task side by side, and a run that is still being written follows live. +**Hive** and **Compare** run the hive-language gallery packages offline through +the `hive_view` example and replay each episode round by round (see +[Hive tab](#hive-tab)). The Hive tab also lists every **recorded run** on this +machine and follows one that is still going (see [Recordings](#recordings)). ```sh cd examples/lab/viewer @@ -12,11 +16,11 @@ npm install HIVE_RUNS=/path/to/harbor/jobs npm run dev # http://:8099 ``` -`npm run dev` binds `0.0.0.0:8099` (`--strictPort`). `HIVE_RUNS` is a +`npm run dev` binds `127.0.0.1:8099` (`--strictPort`). `HIVE_RUNS` is a colon-separated list of directories to scan for `*.jsonl` files (depth 6); the default is `examples/lab/runs/` plus the bundled `public/fixtures/`. There is no authentication, so anyone who can reach the port can read every trace under the -roots. +roots, and can start `hive_view` runs (see [Security](#security)). | Path | Purpose | | --- | --- | @@ -28,8 +32,20 @@ roots. | `server/scan.js` | Finds and describes traces; resolves ids safely | | `server/plugin.js` | Vite plugin serving `/api/runs`, `/api/runs/`, `/api/events` | | `server/scan.test.js` | `npm test`: scanner, arm and path-escape tests | +| `server/hives.js` | Hive packages: listing, name, knob and live-knob validation, argv, NDJSON splitting, the `hive_view` runner | +| `server/hive-api.js` | The `/api/hives` and `/api/compare` routes | +| `server/hives.test.js` | `npm test`: listing, path escape, knob bounds, live availability, model and call-cap validation, argv, line splitting, streaming, kill and timeout | +| `server/recordings.js` | Recordings: the main-checkout resolution, scanning `hive-runs/` for `frames.ndjson`, live detection, safe ids, and tailing a file still being written | +| `server/recordings-api.js` | The `/api/recordings` routes, including the pushed `events` stream | +| `server/recordings.test.js` | `npm test`: scan depth, live and stale, partial lines, id safety, symlink escape, commondir, tailing (split lines, split UTF-8, stale stop), the routes and the events stream | +| `src/hive/` | The Hive and Compare tabs; see [`src/hive/README.md`](src/hive/README.md) | | `tools/probe.mjs`, `tools/live-check.mjs` | Headless-Chromium checks over the DevTools protocol | +| `tools/hive-check.mjs` | Headless check of the Hive and Compare tabs; saves screenshots | +| `tools/fake-hive-view.mjs` | Replays the hive fixtures with `hive_view`'s argv (including `--live`, which needs a key in its env but calls no model), for work without cargo | +| `tools/fake-frames.mjs` | Pure: rewrites a hive-floor fixture into the multi-desk stream (per-desk rounds, referrals, desk endings, live usage); shared by the fake and the page tests | | `public/fixtures/sample.jsonl` | Two demo runs: `hive-4seat` and `baseline-serial` | +| `public/fixtures/hives/` | Hand-written `hive_view` output: structures, frame streams and a compare stream (`.ndjson`, so the runs scanner skips them) | +| `public/fixtures/recordings/` | One hand-made recording (`coin-panel`, two desks) whose turns carry `tool_calls` and `memory`, one of them long enough to fold | ## How runs are found @@ -86,3 +102,195 @@ an event table. Unreadable lines are counted and skipped. `at_ms` is the start for `turn_started` and the end for `turn_finished` and `tool_call`, as documented in core. Time zero is the earliest event of a run. + +## Hive tab + +`?view=hive` opens a gallery with one card per package under +`crates/tinyhivemind-lang/examples/hives/` (or `HIVE_PACKAGES`). **Run** opens +the player and streams one episode from +`hive_view --run --frames`: + +- **Controls**: package, seed, round width, turn budget (blank means the + package's own policy) and, for math packages, the problem. **Play/Pause**, + **Step** (or the arrow keys), speed 0.5× to 8× and the scrubber replay the + buffered rounds, so you can rewind while the stream is still arriving. +- **Live (OpenRouter)**: tick it to run seats as real model calls instead of + the offline simulation, with a model id (default: what the server reports, + `OPENROUTER_MODEL` or `openai/gpt-oss-120b:nitro`) and a call cap (default + and maximum 300). It is disabled, with a note, when the dev server has no + `OPENROUTER_API_KEY`. A live run takes minutes; the player follows the + stream, jumping to each round as it arrives, until you pause, step or + scrub back (**Latest ⇥** resumes following). When the run ends it replays + like any other, and the model usage (calls, tokens, cost, wall time) shows + above the room. Live knobs are never written to the URL, so a reload cannot + start a billed run. +- **Room**: desks are boxes and seats are nodes in their first desk. Topology + links are solid arrows, referral links dashed. The current round's seats + glow, with a dashed ring in a blind round and a solid one in a full round. + A multi-desk package runs one floor per desk, so messages cross desks only + as `referral` frames: each is a packet travelling the declared link it + used. An accepted one reaches the target desk and lights the link; a + refused one is red, stops short and bounces back, labelled with the reason + (hover for the full referral), on a temporary dashed red edge when the + package declares no link between the two desks. A one-floor stream without + referrals still animates an edge the conversation crossed between rounds. + Each desk that ended carries a badge (its ending and topic); the desk whose + ending is the hive's answer is outlined green. Speech + bubbles show what each speaker said. Above 16 seats the nodes go compact + (numbered, unlabelled) and only a few speakers get bubbles; hover or select + a seat for its own. In a math package a seat turns green or red once its + latest answer is right or wrong. +- **Quorum standings**: support and object per topic, a dashed line at the + quorum threshold, and "committed" once the room records it. In a multi-desk + run the panel follows the current round's desk and uses that desk's own + quorum (`desks[].episode`, else the package's). +- **Transcript**: click a seat (or a name in the transcript) to highlight its + latest turn and the lines it saw: the `saw` most recent of the `of` lines in + front of it, counting only earlier rounds for a blind turn and only its own + desk's floor in a multi-desk run. A multi-desk package gets tabs (All, then + one per desk); referral rows (incoming, outgoing, or blocked with the + reason) and desk endings sit between the turns in stream order. +- An ending banner reports converged, deadlocked, exhausted or idle, the topic, + for a math package whether it was correct, and for a multi-desk package + which desk's ending is the answer, followed by a chip per desk ending. + +`?hive=&seed=N` (plus `round_width`, `turn_budget`, `problem`) reopens +and reruns a player. + +## Recordings + +A recording is a run directory holding `frames.ndjson` (the `hive_view --run +--frames` wire contract) and usually `structure.json` (the `--json` +structure). `hive_view --live` and `hive_view --run --record` write both into +`hive-runs/hive-view/-seed-/`, every run started from this +page is recorded the same way, and other hosts (such as the OpenHuman hives) +write their own folders under `hive-runs/`. Turns may carry extra fields the +page shows: `tool_calls: [{tool, summary, ok}]` and `memory: {recalled, +remembered}` (counts or lists). + +The server scans `HIVE_RECORDINGS` (colon-separated) or, by default, the +main checkout's `hive-runs/` (a linked worktree's `.git` file is followed +through `commondir`, as `hive_view` does) plus `public/fixtures/recordings/`, +to depth 5. A folder with no `structure.json` is listed only when its `start` +frame names a gallery package, whose structure stands in. + +- **Live now**: a strip at the top of the Hive tab shows every run still + being written: package, problem or seed, newest round and desk, calls so + far and elapsed time, with a pulsing **LIVE** badge (changed in the last + 30 s) or **QUIET** (no end frame, frames written, quiet for under 10 + minutes; a live round can take that long). Click one to follow it at its + newest round, or ⏮ to replay it from round one (**Latest ⇥** then catches + up). A run that starts while the page is open raises a toast with + **Watch**; with nothing open on the Hive tab the page follows it at once. +- **Recorded runs** on the landing lists live runs first, then the rest, + newest first, with their ending. A finished one replays like any run. +- In the player a recording swaps the run knobs for a recording picker, a + LIVE badge while its tail is followed, and **From start**. The transcript + shows tool calls (🐍 `run_python`, 🔎 `web_search`, 🔧 anything else; ✓ or + ✗) and memory (🧠 ↑recalled ↓remembered) under each turn, and each seat in + the room carries a 🔧/🧠 counter. Turns over 280 characters or four lines + fold until **more** is clicked, and frames are folded once per animation + frame, so recordings of thousands of long turns stay responsive. +- `?recording=` reopens one. + +## Compare tab + +`?compare=math-panel,math-swarm&seeds=N` runs +`hive_view --compare --seeds N --packages a,b` and streams one +result per seed. The single-seat baseline is always included. The page draws +accuracy, convergence and mean rounds per package, and a per-seed strip +(green correct, red wrong, dark no decision). Clicking a cell opens that seed +and problem in the player. + +## Hive API + +- `GET /api/hives` returns `{root, packages: [{name, id, title, seats, desks, + summary, kind}], live_available, live_model_default}`. Structures come from + `hive_view --json`, cached by the package's newest file mtime. + `live_available` is true only when the server's own environment has a + non-empty `OPENROUTER_API_KEY` (the key is never sent); + `live_model_default` is `OPENROUTER_MODEL` when it is a valid id, else + `openai/gpt-oss-120b:nitro`. +- `GET /api/hives/` returns that structure. +- `GET /api/hives//run?seed=&round_width=&turn_budget=&problem=` is a + server-sent-event stream: `status`, then one `frame` per NDJSON line, then + `done`, or `error` with `{message, stderr}` (the stderr tail) on a non-zero + exit. Each line is written as the child prints it, with a `: keepalive` + comment every 15 s so a quiet live run is not dropped by a proxy. + `&live=1&model=&max_calls=N` makes it a live run: + `hive_view ... --live --model --max-calls `. It is a 400 unless + `live_available`, unless the model matches + `^[A-Za-z0-9][A-Za-z0-9._\-/]{0,99}(:[A-Za-z0-9._-]{1,30})?$` and contains + none of `online`, `search`, `sonar` or `web` (any case, anywhere; the same + list `hive_view --live` refuses), and unless `max_calls` is 1..300 (default + 300). A live run is stopped after 30 minutes; offline runs have no cap. +- `GET /api/compare?packages=a,b&seeds=N` streams `seed` events and a final + `summary`, then `done` or `error`. +- `GET /api/recordings` returns `{roots, recordings: [{id, path, package, + problem, model, seed, round, desk, turns, tool_calls, calls, started, + frames, size, mtime, live, active, ended, ending, correct, structure}]}`, + newest change first. `live` is no `end` frame and a change within 30 s; + `active` the same within 20 minutes. Ids are `:` + and resolve only to a `frames.ndjson` inside its root (symlinks included). +- `GET /api/recordings/events` pushes one `recordings` event with that listing + at once and again whenever a recording appears, grows, goes quiet or ends + (rescanned every second while a page listens). +- `GET /api/recordings//structure` returns its `structure.json`, or its + package's structure. +- `GET /api/recordings//frames` streams `status` (`{live, active, + ended}`), one `frame` per line already written, then, while the run is + `active`, every line appended (polled every 500 ms; a partial last line + waits for its newline), and `done` (`{ended}`) after the `end` frame, or + once the file has not grown for 20 minutes. + +The first request builds the binary once with +`cargo build -p tinyhivemind-lang --example hive_view` (concurrent requests +share one build) and then runs `target/debug/examples/hive_view`. +`HIVE_PACKAGES` replaces the package root. `HIVE_VIEW_BIN` replaces the binary +and skips the build. The child's environment is the server's own. A `.mjs` runs under node, so +`HIVE_VIEW_BIN=tools/fake-hive-view.mjs` replays the fixtures; +`HIVE_FAKE_DELAY_MS=3000` slows its replay to live pace. + +## Security + +There is no authentication. Anyone who can reach the port can read every +trace and recording under the roots (recordings carry model replies and tool +summaries) and make the server run `hive_view`, which costs CPU. +Hive runs are fenced in: every process is spawned from an argv array with no +shell, `` must be a package from the listing (anything else is a 404, +`..` included), and every knob is a plain integer inside its bounds (seed +0..2^32, round width 1..64, turn budget 1..2000, seeds 1..200) or the request +is a 400. A problem id must be short and alphanumeric. The child is killed +when the client disconnects. A live run spends money on the server's +OpenRouter key. Paid execution is restricted to a loopback client using a +localhost or loopback Host header and a matching Origin when supplied; +cross-site browser requests are rejected. Each run allows up to 300 calls. The key reaches `hive_view` only through the environment it +inherits, never argv, a response or a log; model ids naming `online`, `search`, +`sonar` or `web` are refused so a live run cannot browse. The default listener +binds to `127.0.0.1`; trace and recording reads still have no authentication. + +## Checks + +```sh +npm test # node --test: server and pure page modules +npm run build # static page (Runs tab drag and drop only) +chrome --headless=new --remote-debugging-port=9333 --user-data-dir=/somewhere & +node tools/hive-check.mjs http://localhost:8099/ shots # SCHEME=light for light mode +``` + +`hive-check.mjs` opens the gallery, `?hive=review-board`, `?hive=math-swarm` +(asserting five desk badges and ending rows and at least one accepted and one +refused referral row, and shooting `math-swarm-end.png` and one desk's +transcript tab) +and `?compare=math-panel,math-swarm&seeds=10`, waits for each ending, replays +a seed from the compare strip, and writes PNGs to `shots/` (git-ignored). It +also opens the `coin-panel` fixture through `?recording=` and asserts its +tool-call, memory, seat-counter and fold badges (`recording.png`). With +`FOLLOW_LIVE=1` it then waits for a run in the Live now strip (start one with +`hive_view --run --live --max-calls 80` from a terminal), follows +it, checks the player advances, and shoots `live-strip.png`, `live-mid.png` +and `live-end.png`. + +Paid live runs require a loopback client, a localhost or loopback Host header, +and a matching Origin when supplied. Cross-site browser requests are refused +before spawning a run. The dev and preview listeners bind to `127.0.0.1` by default. diff --git a/examples/lab/viewer/index.html b/examples/lab/viewer/index.html index 409f93e1..95653def 100644 --- a/examples/lab/viewer/index.html +++ b/examples/lab/viewer/index.html @@ -4,7 +4,7 @@ -Hive Lab Run Viewer +Hive Lab Viewer