Initial import of sim-package

This commit is contained in:
2026-04-15 17:16:30 +00:00
commit 39fcec1847
14 changed files with 3152 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
# sim-economy
Multi-agent economic simulation where LLM agents operate inside a blockchain-enforced token economy. Agents mine, stake, burn tokens to validate a ledger, propose and sign contracts, sell information goods, and pay for their own inference from within the simulation.
## Architecture
```
sim-orchestrator (Python)
↓ HTTP
sim-engine (Rust / Axum)
↓ SQLite
sim.db
```
**sim-engine** — authoritative world state. Rust + Axum HTTP API + SQLite ledger. Handles block production, fork resolution (Slimcoin-style PoW-as-clock rule), contract lifecycle, inference billing, dividend distribution to core shareholders.
**sim-orchestrator** — drives the turn loop. Calls Ollama for each agent per turn, injects world state as context, parses JSON actions, POSTs to the engine.
## Token Economy
- Tokens are compute-backed: generating output costs tokens, paid to CPU core owners
- Below a commons threshold, inference is free for all agents
- Core shares (4 cores, auctioned turn 1) pay dividends from all inference fees above threshold
- Negative balance accrues interest (destroyed, deflationary)
- Three validation mechanisms: Mine (PoW, action budget cost), Stake (locked tokens, PoS), Burn (permanent destruction, PoB)
- Slimcoin rule: stake/burn blocks can only follow a mine block. Mine is the clock.
- Winner-takes-all block lottery — pools must be formed via contracts
## Actions per turn
`mine` `stake` `unstake` `burn` `study` `job` `transfer` `propose_contract` `sign_contract` `confirm_delivery` `dispute_delivery` `arbitrator_ruling` `sell_information` `bid_core` `speak`
## Contracts
Three-signature contracts (proposer + counterparty + arbitrator). Arbitrator named at proposal time, posts collateral. Automatic settlement (engine executes on-chain condition) or attested (both parties confirm, arbitrator resolves disputes). Slash mechanics on default/dispute.
Contract types: `forward` `loan` `service` `insurance` `information_sale` `pool`
## HTTP API (sim-engine)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/init` | Initialize world, genesis block, agents |
| POST | `/turn` | Submit all agent actions for current turn |
| GET | `/state` | Full world state snapshot |
| GET | `/agent/:id` | Single agent state |
| GET | `/contract/:id` | Single contract |
| GET | `/speech/:turn` | Public speech log for a turn |
| GET | `/config` | World config |
## Running
### Prerequisites
- Docker + Docker Compose
- Ollama running on host with a model pulled: `ollama pull gemma3:27b`
### Start
```bash
# Build and start the engine
docker compose up --build engine
# Run the orchestrator (once engine is healthy)
docker compose run orchestrator
```
### Config
Edit `sim-orchestrator/orchestrator.py`:
```python
MODEL = "gemma3:27b" # your Ollama model tag
AGENT_IDS = [f"agent_{i}" for i in range(8)] # number of agents
TURNS = 50 # simulation length
```
Edit `WORLD_CONFIG` dict in the same file to tune economy parameters.
### Without Docker
```bash
# Engine
cd sim-engine
cargo build --release
DB_PATH=sim.db PORT=3000 ./target/release/sim-engine
# Orchestrator
cd sim-orchestrator
pip install httpx
python orchestrator.py
```
## What to watch for
**Turn 1** — core auction. Do agents understand cores = passive income and bid accordingly?
**Turns 515** — first contracts. Watch speech log (`GET /speech/:turn`) for coordination attempts before formal proposals.
**Turns 15+** — pool contracts. If agents understand variance reduction, you'll see multi-party pool agreements. This is the most interesting emergent behavior to look for.
**SQLite analytics** — all blockchain history is in `sim.db`. After a run:
```bash
sqlite3 sim.db "SELECT turn, agent_id, message FROM speech_log ORDER BY turn"
sqlite3 sim.db "SELECT agent_id, balance, staked, burn_score, study_level FROM agents"
sqlite3 sim.db "SELECT * FROM contracts WHERE status='settled'"
```
## Wiring into existing sims (Sims-style worlds)
The engine's HTTP API works as a tool-call set for any agent framework. Expose these 5 endpoints as tools:
- `ledger_transfer(to, amount, fee)``POST /turn` with Transfer action
- `ledger_balance()``GET /agent/:id`
- `ledger_propose_contract(...)``POST /turn` with ProposeContract
- `ledger_sign_contract(id, role)``POST /turn` with SignContract
- `ledger_mine()``POST /turn` with Mine action
Any agent framework that supports tool calls (LlamaIndex ReActAgent, CrewAI, AutoGen, aphae, AgentSims) can use this ledger as its currency layer with ~1 day of wiring work.
## Files
```
sim-engine/
src/
types.rs — all data structures (Transaction, Block, Contract, AgentState, ...)
ledger.rs — SQLite persistence
blockchain.rs — block production, fork resolution, validation weight, burn decay
contracts.rs — contract lifecycle (propose → sign → settle/dispute → ruling)
engine.rs — turn processing, billing, dividend distribution
main.rs — Axum HTTP API
Cargo.toml
Dockerfile
sim-orchestrator/
orchestrator.py — turn loop, Ollama calls, action parsing, core auction
context.py — agent context builder (what each agent sees per turn)
Dockerfile
docker-compose.yml
```
+24
View File
@@ -0,0 +1,24 @@
services:
engine:
build: ./sim-engine
ports:
- "3000:3000"
volumes:
- sim-data:/data
environment:
DB_PATH: /data/sim.db
PORT: "3000"
restart: unless-stopped
orchestrator:
build: ./sim-orchestrator
depends_on:
- engine
environment:
ENGINE_URL: http://engine:3000
OLLAMA_URL: http://host.docker.internal:11434 # Ollama running on host
MODEL: gemma3:27b
restart: "no" # runs once per simulation
volumes:
sim-data:
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "sim-engine"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "sim-engine"
path = "src/main.rs"
[dependencies]
axum = { version = "0.7", features = ["json"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
sha2 = "0.10"
hex = "0.4"
rand = "0.8"
thiserror = "1"
uuid = { version = "1", features = ["v4"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
+15
View File
@@ -0,0 +1,15 @@
FROM rust:1.78-slim AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock* ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/sim-engine .
ENV DB_PATH=/data/sim.db
ENV PORT=3000
VOLUME ["/data"]
EXPOSE 3000
CMD ["./sim-engine"]
+315
View File
@@ -0,0 +1,315 @@
use sha2::{Sha256, Digest};
use rand::Rng;
use std::collections::HashMap;
use crate::types::*;
use crate::ledger::Ledger;
use crate::error::SimError;
// ─── Hashing helpers ─────────────────────────────────────────────────────────
pub fn hash(data: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(data.as_bytes());
hex::encode(hasher.finalize())
}
pub fn tx_id(sender: &str, receiver: Option<&str>, amount: Tokens, nonce: u64, turn: u64) -> TxId {
hash(&format!("{}:{}:{}:{}:{}", sender, receiver.unwrap_or(""), amount, nonce, turn))
}
pub fn block_id(prev: &str, validator: &str, turn: u64, merkle: &str) -> BlockId {
hash(&format!("{}:{}:{}:{}", prev, validator, turn, merkle))
}
pub fn merkle_root(tx_ids: &[TxId]) -> String {
if tx_ids.is_empty() {
return hash("empty");
}
let combined = tx_ids.join(":");
hash(&combined)
}
// ─── Validation weight ───────────────────────────────────────────────────────
pub fn compute_validation_weight(
contribution: &ValidationContribution,
config: &WorldConfig,
) -> f64 {
match contribution.validator_type {
ValidatorType::Mine => config.mine_base_weight,
ValidatorType::Stake => contribution.weight * config.stake_weight_per_token,
ValidatorType::Burn => contribution.weight * config.burn_weight_per_token,
ValidatorType::Genesis => 0.0,
}
}
// ─── Block ordering constraint ────────────────────────────────────────────────
// Slimcoin rule: stake and burn blocks can only appear after a mine block.
// Two non-mine blocks cannot appear consecutively.
pub fn can_produce_block(
validator_type: &ValidatorType,
last_block: Option<&Block>,
) -> bool {
match validator_type {
ValidatorType::Mine | ValidatorType::Genesis => true,
ValidatorType::Stake | ValidatorType::Burn => {
match last_block {
// stake/burn must follow a mine block
Some(b) => b.validator_type == ValidatorType::Mine,
None => false, // no blocks yet, only mine can start
}
}
}
}
// ─── Burn proof verification ─────────────────────────────────────────────────
// Burn tx must be confirmed on-chain and at least burn_maturity_turns old.
// Prevents burning and immediately claiming the block before chain can reorg.
pub fn verify_burn_proof(
burn_tx_id: &TxId,
agent_id: &str,
current_turn: u64,
ledger: &Ledger,
config: &WorldConfig,
) -> Result<bool, SimError> {
match ledger.get_tx(burn_tx_id)? {
None => Ok(false),
Some(tx) => {
// must be a confirmed burn tx (has a block_id — checked via get_mature_burn_txs)
// must belong to the claiming agent
// must be old enough
let age = current_turn.saturating_sub(tx.turn);
Ok(tx.sender == agent_id
&& tx.tx_type == TxType::Burn
&& age >= config.burn_maturity_turns)
}
}
}
// ─── Duplicate validation detection ─────────────────────────────────────────
// If an agent signed blocks on two different forks this turn, slash them.
pub fn detect_and_slash_duplicate(
agent_id: &str,
turn: u64,
ledger: &Ledger,
) -> Result<bool, SimError> {
ledger.has_duplicate_validation(agent_id, turn)
}
// ─── Block lottery ────────────────────────────────────────────────────────────
// Winner-takes-all: one agent wins per block.
// Each contributor gets one draw, probability proportional to their weight.
pub fn draw_block_winner(
contributions: &[ValidationContribution],
config: &WorldConfig,
) -> Option<AgentId> {
let weights: Vec<(AgentId, f64)> = contributions
.iter()
.map(|c| (c.agent_id.clone(), compute_validation_weight(c, config)))
.filter(|(_, w)| *w > 0.0)
.collect();
if weights.is_empty() {
return None;
}
let total: f64 = weights.iter().map(|(_, w)| w).sum();
let mut rng = rand::thread_rng();
let mut pick = rng.gen::<f64>() * total;
for (agent_id, weight) in &weights {
pick -= weight;
if pick <= 0.0 {
return Some(agent_id.clone());
}
}
// floating point edge case — return last
weights.last().map(|(id, _)| id.clone())
}
// ─── Block production ────────────────────────────────────────────────────────
pub struct BlockProducer<'a> {
pub ledger: &'a Ledger,
pub config: &'a WorldConfig,
}
impl<'a> BlockProducer<'a> {
pub fn new(ledger: &'a Ledger, config: &'a WorldConfig) -> Self {
Self { ledger, config }
}
/// Attempt to produce a block this turn.
/// Returns (block, winner_id, fees) if successful, None if threshold not met.
pub fn attempt_block(
&self,
turn: u64,
contributions: &[ValidationContribution],
mempool: &[Transaction],
agents: &mut HashMap<AgentId, AgentState>,
) -> Result<Option<(Block, AgentId, Tokens)>, SimError> {
// ── Check ordering constraint ────────────────────────────────────────
let last_block = self.ledger.get_chain_tip()?
.and_then(|tip| self.ledger.get_block(&tip.block_id).ok()?);
// group contributions by type; pick the dominant type for this block
// (in practice one turn's contributions should be one type, but enforce anyway)
let mine_contribs: Vec<&ValidationContribution> = contributions.iter()
.filter(|c| c.validator_type == ValidatorType::Mine)
.collect();
let stake_contribs: Vec<&ValidationContribution> = contributions.iter()
.filter(|c| c.validator_type == ValidatorType::Stake)
.collect();
let burn_contribs: Vec<&ValidationContribution> = contributions.iter()
.filter(|c| c.validator_type == ValidatorType::Burn)
.collect();
// determine which block type to attempt
// priority: mine > stake > burn (mine is the clock)
let (block_type, effective_contributions): (ValidatorType, Vec<ValidationContribution>) =
if !mine_contribs.is_empty() {
(ValidatorType::Mine, mine_contribs.into_iter().cloned().collect())
} else if !stake_contribs.is_empty()
&& can_produce_block(&ValidatorType::Stake, last_block.as_ref())
{
(ValidatorType::Stake, stake_contribs.into_iter().cloned().collect())
} else if !burn_contribs.is_empty()
&& can_produce_block(&ValidatorType::Burn, last_block.as_ref())
{
// verify all burn proofs
let valid: Vec<ValidationContribution> = burn_contribs.into_iter()
.filter(|c| {
if let Some(ref proof_tx_id) = c.burn_proof_tx_id {
self.verify_burn_contrib(&c.agent_id, proof_tx_id, turn)
.unwrap_or(false)
} else {
false
}
})
.cloned()
.collect();
(ValidatorType::Burn, valid)
} else {
return Ok(None); // nothing valid this turn
};
// ── Check threshold ──────────────────────────────────────────────────
let total_weight: f64 = effective_contributions.iter()
.map(|c| compute_validation_weight(c, self.config))
.sum();
if total_weight < self.config.block_threshold {
return Ok(None); // mempool carries over
}
// ── Draw winner ───────────────────────────────────────────────────────
let winner_id = match draw_block_winner(&effective_contributions, self.config) {
Some(w) => w,
None => return Ok(None),
};
// ── Slash duplicate validators ─────────────────────────────────────────
for contrib in &effective_contributions {
if detect_and_slash_duplicate(&contrib.agent_id, turn, self.ledger)? {
if let Some(agent) = agents.get_mut(&contrib.agent_id) {
let slash = (agent.staked as f64 * 0.5) as Tokens;
agent.staked -= slash;
agent.balance -= slash; // destroyed, deflationary
}
}
}
// ── Burn decay ────────────────────────────────────────────────────────
// Each mine block causes all burn scores to decay
if block_type == ValidatorType::Mine {
for agent in agents.values_mut() {
agent.burn_score *= 1.0 - self.config.burn_decay_rate;
}
}
// ── Assemble block ────────────────────────────────────────────────────
let fees: Tokens = mempool.iter().map(|tx| tx.fee).sum();
let tx_ids: Vec<TxId> = mempool.iter().map(|tx| tx.tx_id.clone()).collect();
let merkle = merkle_root(&tx_ids);
let tip = self.ledger.get_chain_tip()?;
let (prev_id, height) = match tip {
Some(t) => (t.block_id, t.height + 1),
None => (hash("genesis"), 0),
};
let burn_proof = effective_contributions.iter()
.find(|c| c.agent_id == winner_id)
.and_then(|c| c.burn_proof_tx_id.clone());
let winner_score = effective_contributions.iter()
.find(|c| c.agent_id == winner_id)
.map(|c| compute_validation_weight(c, self.config))
.unwrap_or(0.0);
let blk = Block {
block_id: block_id(&prev_id, &winner_id, turn, &merkle),
prev_block_id: prev_id,
turn,
height,
validator_id: winner_id.clone(),
validator_type: block_type,
effective_score: winner_score,
transactions: tx_ids,
merkle_root: merkle,
fees_collected: fees,
burn_proof_tx_id: burn_proof,
};
// cumulative weight = parent cumulative + this block's winner score
let parent_weight = self.ledger.get_chain_tip()?
.map(|t| t.cumulative_weight)
.unwrap_or(0.0);
let cumulative = parent_weight + winner_score;
self.ledger.insert_block(&blk, cumulative)?;
self.ledger.finalize_transactions(&blk.transactions, &blk.block_id)?;
// winner receives all fees
if let Some(winner) = agents.get_mut(&winner_id) {
winner.balance += fees;
}
Ok(Some((blk, winner_id, fees)))
}
fn verify_burn_contrib(
&self,
agent_id: &str,
burn_tx_id: &str,
current_turn: u64,
) -> Result<bool, SimError> {
verify_burn_proof(&burn_tx_id.to_string(), agent_id, current_turn, self.ledger, self.config)
}
}
// ─── Fork resolution ──────────────────────────────────────────────────────────
// Not needed in normal operation — the engine always builds on the canonical tip.
// This is used if two competing chain tips exist (should be rare; the engine is
// single-threaded and authoritative). Returns the id of the heavier chain tip.
pub fn resolve_fork(tip_a: &ChainTip, tip_b: &ChainTip) -> &'static str {
// highest cumulative weight wins; tie-break by height, then lexicographic block_id
if tip_a.cumulative_weight > tip_b.cumulative_weight {
"a"
} else if tip_b.cumulative_weight > tip_a.cumulative_weight {
"b"
} else if tip_a.height > tip_b.height {
"a"
} else if tip_b.height > tip_a.height {
"b"
} else if tip_a.block_id > tip_b.block_id {
"a"
} else {
"b"
}
}
+463
View File
@@ -0,0 +1,463 @@
use uuid::Uuid;
use std::collections::HashMap;
use crate::types::*;
use crate::ledger::Ledger;
use crate::error::SimError;
use crate::blockchain::hash;
pub struct ContractEngine<'a> {
pub ledger: &'a Ledger,
pub config: &'a WorldConfig,
}
impl<'a> ContractEngine<'a> {
pub fn new(ledger: &'a Ledger, config: &'a WorldConfig) -> Self {
Self { ledger, config }
}
/// Agent proposes a contract. Proposer's collateral is locked immediately.
/// Status: Proposed. Waiting for counterparty and arbitrator to sign.
pub fn propose(
&self,
proposer_id: &AgentId,
proposal: ContractProposal,
turn: u64,
agents: &mut HashMap<AgentId, AgentState>,
) -> Result<Contract, SimError> {
let proposer = agents.get_mut(proposer_id)
.ok_or_else(|| SimError::AgentNotFound(proposer_id.clone()))?;
// check proposer can cover their collateral
if proposer.balance < proposal.collateral.proposer_locked {
return Err(SimError::InsufficientBalance {
agent: proposer_id.clone(),
required: proposal.collateral.proposer_locked,
available: proposer.balance,
});
}
proposer.balance -= proposal.collateral.proposer_locked;
let contract_id = Uuid::new_v4().to_string();
let proposer_sig = Some(hash(&format!("{}:{}", proposer_id, contract_id)));
let contract = Contract {
contract_id: contract_id.clone(),
proposer: proposer_id.clone(),
counterparty: proposal.counterparty,
arbitrator: proposal.arbitrator,
contract_type: proposal.contract_type,
terms: proposal.terms,
collateral: proposal.collateral,
status: ContractStatus::Proposed,
settlement_type: proposal.settlement_type,
penalty: proposal.penalty,
arbitrator_fee: proposal.arbitrator_fee,
proposer_sig,
counterparty_sig: None,
arbitrator_sig: None,
proposer_confirmed: None,
counterparty_confirmed: None,
arbitrator_ruling: None,
created_turn: turn,
dispute_deadline_turn: None,
payload: proposal.payload,
};
self.ledger.upsert_contract(&contract)?;
Ok(contract)
}
/// Counterparty or arbitrator signs the contract.
/// When all three have signed: status → Active, all collateral locked.
pub fn sign(
&self,
contract_id: &ContractId,
signer_id: &AgentId,
role: &SignerRole,
agents: &mut HashMap<AgentId, AgentState>,
) -> Result<Contract, SimError> {
let mut contract = self.ledger.get_contract(contract_id)?
.ok_or_else(|| SimError::ContractNotFound(contract_id.clone()))?;
if contract.status != ContractStatus::Proposed
&& contract.status != ContractStatus::Countersigned
{
return Err(SimError::InvalidContractState(
contract_id.clone(),
format!("{:?}", contract.status),
));
}
match role {
SignerRole::Counterparty => {
if signer_id != &contract.counterparty {
return Err(SimError::UnauthorizedSigner(signer_id.clone()));
}
let agent = agents.get_mut(signer_id)
.ok_or_else(|| SimError::AgentNotFound(signer_id.clone()))?;
if agent.balance < contract.collateral.counterparty_locked {
return Err(SimError::InsufficientBalance {
agent: signer_id.clone(),
required: contract.collateral.counterparty_locked,
available: agent.balance,
});
}
agent.balance -= contract.collateral.counterparty_locked;
contract.counterparty_sig = Some(
hash(&format!("{}:{}", signer_id, contract_id))
);
// move to countersigned if arbitrator hasn't signed yet
if contract.arbitrator_sig.is_none() {
contract.status = ContractStatus::Countersigned;
}
}
SignerRole::Arbitrator => {
if signer_id != &contract.arbitrator {
return Err(SimError::UnauthorizedSigner(signer_id.clone()));
}
let agent = agents.get_mut(signer_id)
.ok_or_else(|| SimError::AgentNotFound(signer_id.clone()))?;
if agent.balance < contract.collateral.arbitrator_locked {
return Err(SimError::InsufficientBalance {
agent: signer_id.clone(),
required: contract.collateral.arbitrator_locked,
available: agent.balance,
});
}
agent.balance -= contract.collateral.arbitrator_locked;
contract.arbitrator_sig = Some(
hash(&format!("{}:{}", signer_id, contract_id))
);
let agent = agents.get_mut(signer_id).unwrap();
agent.arbitration_active += 1;
}
}
// all three signed → Active
if contract.proposer_sig.is_some()
&& contract.counterparty_sig.is_some()
&& contract.arbitrator_sig.is_some()
{
contract.status = ContractStatus::Active;
}
self.ledger.upsert_contract(&contract)?;
Ok(contract)
}
/// Process all contracts due this turn.
/// Automatic contracts settle if condition is met.
/// Attested contracts check confirmations and apply penalties if timed out.
pub fn process_due_contracts(
&self,
turn: u64,
agents: &mut HashMap<AgentId, AgentState>,
last_block_winner: Option<&AgentId>,
) -> Result<(Vec<ContractId>, Vec<ContractId>), SimError> {
let due = self.ledger.get_contracts_due(turn)?;
let mut settled = Vec::new();
let mut defaulted = Vec::new();
for mut contract in due {
match contract.settlement_type {
SettlementType::Automatic => {
if self.evaluate_condition(&contract, last_block_winner) {
self.settle(&mut contract, agents)?;
settled.push(contract.contract_id.clone());
} else {
self.default_contract(&mut contract, agents)?;
defaulted.push(contract.contract_id.clone());
}
}
SettlementType::Attested => {
let deadline = contract.dispute_deadline_turn
.unwrap_or(contract.terms.delivery_turn
+ self.config.attested_confirmation_window);
if turn >= deadline {
// timeout — neither confirmed or one didn't
match (contract.proposer_confirmed, contract.counterparty_confirmed) {
(Some(true), Some(true)) => {
self.settle(&mut contract, agents)?;
settled.push(contract.contract_id.clone());
}
(Some(true), _) | (_, Some(true)) => {
// one confirmed, other didn't — other defaults
self.default_contract(&mut contract, agents)?;
defaulted.push(contract.contract_id.clone());
}
_ => {
if self.config.slash_both_on_timeout {
self.slash_both(&mut contract, agents)?;
}
defaulted.push(contract.contract_id.clone());
}
}
}
// if not timed out yet, leave active — waiting for confirmations
}
}
}
// process disputed contracts where arbitrator has ruled
let active = self.ledger.get_active_contracts()?;
for mut contract in active {
if contract.status == ContractStatus::Disputed {
if let Some(ref ruling) = contract.arbitrator_ruling.clone() {
self.apply_arbitrator_ruling(&mut contract, ruling, agents)?;
settled.push(contract.contract_id.clone());
}
}
}
Ok((settled, defaulted))
}
/// One party confirms delivery. If both confirm → settle on next tick.
pub fn confirm_delivery(
&self,
contract_id: &ContractId,
confirmer_id: &AgentId,
agents: &mut HashMap<AgentId, AgentState>,
) -> Result<Contract, SimError> {
let mut contract = self.ledger.get_contract(contract_id)?
.ok_or_else(|| SimError::ContractNotFound(contract_id.clone()))?;
if contract.status != ContractStatus::Active {
return Err(SimError::InvalidContractState(
contract_id.clone(),
format!("{:?}", contract.status),
));
}
if confirmer_id == &contract.proposer {
contract.proposer_confirmed = Some(true);
} else if confirmer_id == &contract.counterparty {
contract.counterparty_confirmed = Some(true);
} else {
return Err(SimError::UnauthorizedSigner(confirmer_id.clone()));
}
// both confirmed → settle immediately
if contract.proposer_confirmed == Some(true)
&& contract.counterparty_confirmed == Some(true)
{
self.settle(&mut contract, agents)?;
} else {
self.ledger.upsert_contract(&contract)?;
}
Ok(contract)
}
/// One party disputes delivery. Status → Disputed; arbitrator must rule.
pub fn dispute_delivery(
&self,
contract_id: &ContractId,
disputer_id: &AgentId,
_agents: &mut HashMap<AgentId, AgentState>,
current_turn: u64,
) -> Result<Contract, SimError> {
let mut contract = self.ledger.get_contract(contract_id)?
.ok_or_else(|| SimError::ContractNotFound(contract_id.clone()))?;
if contract.status != ContractStatus::Active {
return Err(SimError::InvalidContractState(
contract_id.clone(),
format!("{:?}", contract.status),
));
}
if disputer_id != &contract.proposer && disputer_id != &contract.counterparty {
return Err(SimError::UnauthorizedSigner(disputer_id.clone()));
}
contract.status = ContractStatus::Disputed;
contract.dispute_deadline_turn = Some(
current_turn + self.config.attested_confirmation_window * 2
);
self.ledger.upsert_contract(&contract)?;
Ok(contract)
}
/// Arbitrator submits ruling. Applied on next process_due_contracts call.
pub fn arbitrator_ruling(
&self,
contract_id: &ContractId,
arbitrator_id: &AgentId,
ruling_for: AgentId,
) -> Result<Contract, SimError> {
let mut contract = self.ledger.get_contract(contract_id)?
.ok_or_else(|| SimError::ContractNotFound(contract_id.clone()))?;
if arbitrator_id != &contract.arbitrator {
return Err(SimError::UnauthorizedSigner(arbitrator_id.clone()));
}
if contract.status != ContractStatus::Disputed {
return Err(SimError::InvalidContractState(
contract_id.clone(),
format!("{:?}", contract.status),
));
}
if ruling_for != contract.proposer && ruling_for != contract.counterparty {
return Err(SimError::InvalidRuling(ruling_for));
}
contract.arbitrator_ruling = Some(ruling_for);
self.ledger.upsert_contract(&contract)?;
Ok(contract)
}
// ─── Internal settlement logic ────────────────────────────────────────────
fn settle(
&self,
contract: &mut Contract,
agents: &mut HashMap<AgentId, AgentState>,
) -> Result<(), SimError> {
// transfer price from counterparty to proposer (already locked in collateral)
// release all collateral
self.release_collateral(contract, agents);
// pay price: counterparty → proposer
// (price was locked as part of counterparty collateral for forward/sale contracts)
if let Some(p) = agents.get_mut(&contract.proposer) {
p.balance += contract.terms.price;
}
if let Some(cp) = agents.get_mut(&contract.counterparty) {
cp.balance = cp.balance.saturating_sub(contract.terms.price);
}
// pay arbitrator their fee
if let Some(arb) = agents.get_mut(&contract.arbitrator) {
arb.balance += contract.arbitrator_fee;
arb.arbitration_wins += 1; // no dispute means clean outcome
arb.arbitration_active = arb.arbitration_active.saturating_sub(1);
}
contract.status = ContractStatus::Settled;
self.ledger.upsert_contract(contract)?;
Ok(())
}
fn default_contract(
&self,
contract: &mut Contract,
agents: &mut HashMap<AgentId, AgentState>,
) -> Result<(), SimError> {
// proposer defaults — slash their collateral, return counterparty's
let slash = contract.collateral.proposer_locked;
// slashed tokens destroyed (deflationary) — just don't add them back
if let Some(cp) = agents.get_mut(&contract.counterparty) {
cp.balance += contract.collateral.counterparty_locked;
cp.balance += slash / 2; // half of slash goes to counterparty as damages
}
// arbitrator released
if let Some(arb) = agents.get_mut(&contract.arbitrator) {
arb.balance += contract.collateral.arbitrator_locked;
arb.arbitration_active = arb.arbitration_active.saturating_sub(1);
}
contract.status = ContractStatus::Defaulted;
self.ledger.upsert_contract(contract)?;
Ok(())
}
fn slash_both(
&self,
contract: &mut Contract,
agents: &mut HashMap<AgentId, AgentState>,
) -> Result<(), SimError> {
// both collaterals destroyed; arbitrator keeps theirs + penalty from both
let total_penalty = contract.collateral.proposer_locked
+ contract.collateral.counterparty_locked;
if let Some(arb) = agents.get_mut(&contract.arbitrator) {
arb.balance += contract.collateral.arbitrator_locked + total_penalty / 2;
arb.arbitration_active = arb.arbitration_active.saturating_sub(1);
}
contract.status = ContractStatus::Defaulted;
self.ledger.upsert_contract(contract)?;
Ok(())
}
fn apply_arbitrator_ruling(
&self,
contract: &mut Contract,
ruling_for: &AgentId,
agents: &mut HashMap<AgentId, AgentState>,
) -> Result<(), SimError> {
let ruling_against = if ruling_for == &contract.proposer {
contract.counterparty.clone()
} else {
contract.proposer.clone()
};
// winner gets their collateral back + penalty from loser
// loser's collateral slashed; half to winner, half to arbitrator
let loser_collateral = if ruling_against == contract.proposer {
contract.collateral.proposer_locked
} else {
contract.collateral.counterparty_locked
};
let winner_collateral = if ruling_for == &contract.proposer {
contract.collateral.proposer_locked
} else {
contract.collateral.counterparty_locked
};
if let Some(winner) = agents.get_mut(ruling_for) {
winner.balance += winner_collateral + loser_collateral / 2;
}
// arbitrator: gets their collateral back + fee + half the loser's collateral
if let Some(arb) = agents.get_mut(&contract.arbitrator) {
arb.balance += contract.collateral.arbitrator_locked
+ contract.arbitrator_fee
+ loser_collateral / 2;
arb.arbitration_wins += 1;
arb.arbitration_active = arb.arbitration_active.saturating_sub(1);
}
// track loss for ruling_against
if let Some(loser) = agents.get_mut(&ruling_against) {
loser.arbitration_losses += 1;
}
contract.status = ContractStatus::Settled;
self.ledger.upsert_contract(contract)?;
Ok(())
}
fn release_collateral(
&self,
contract: &Contract,
agents: &mut HashMap<AgentId, AgentState>,
) {
if let Some(p) = agents.get_mut(&contract.proposer) {
p.balance += contract.collateral.proposer_locked;
}
if let Some(cp) = agents.get_mut(&contract.counterparty) {
cp.balance += contract.collateral.counterparty_locked;
}
// arbitrator collateral returned via settle()
}
fn evaluate_condition(
&self,
contract: &Contract,
last_block_winner: Option<&AgentId>,
) -> bool {
match &contract.terms.condition {
None => true, // unconditional forward — always settles at delivery_turn
Some(condition) => {
// simple condition evaluator for on-chain facts
// real implementation would parse a proper expression
if condition.starts_with("block_winner==") {
let expected = condition.trim_start_matches("block_winner==");
last_block_winner.map(|w| w == expected).unwrap_or(false)
} else {
// unknown condition type defaults to false (safer)
false
}
}
}
}
}
+430
View File
@@ -0,0 +1,430 @@
use std::collections::HashMap;
use uuid::Uuid;
use crate::types::*;
use crate::ledger::Ledger;
use crate::blockchain::{BlockProducer, hash, tx_id};
use crate::contracts::ContractEngine;
use crate::error::SimError;
pub struct Engine<'a> {
pub ledger: &'a Ledger,
pub config: &'a WorldConfig,
}
impl<'a> Engine<'a> {
pub fn new(ledger: &'a Ledger, config: &'a WorldConfig) -> Self {
Self { ledger, config }
}
/// Process one full turn: all agent actions → block attempt → contract settlement → billing.
pub fn process_turn(
&self,
turn: u64,
agent_inputs: Vec<AgentTurnInput>,
) -> Result<TurnResult, SimError> {
let mut agents: HashMap<AgentId, AgentState> = self.ledger
.get_all_agents()?
.into_iter()
.map(|a| (a.agent_id.clone(), a))
.collect();
let mut result = TurnResult {
turn,
block_produced: None,
block_winner: None,
transactions_settled: Vec::new(),
transactions_pending: Vec::new(),
contracts_settled: Vec::new(),
contracts_defaulted: Vec::new(),
inference_fees_collected: 0,
dividends_paid: HashMap::new(),
interest_charged: HashMap::new(),
errors: Vec::new(),
};
let mut validation_contributions: Vec<ValidationContribution> = Vec::new();
// ── Phase 1: Process all agent actions ─────────────────────────────────
for input in &agent_inputs {
if let Err(e) = self.process_agent_action(
input,
turn,
&mut agents,
&mut validation_contributions,
&mut result,
) {
result.errors.push((input.agent_id.clone(), e.to_string()));
}
// log speech
if let Some(ref speech) = input.speech {
self.ledger.log_speech(turn, &input.agent_id, speech)?;
}
}
// ── Phase 2: Billing ──────────────────────────────────────────────────
let core_shares = self.ledger.get_inference_share_per_agent()?;
let mut total_inference_fees: Tokens = 0;
for input in &agent_inputs {
if let Some(agent) = agents.get_mut(&input.agent_id) {
// check if agent used job action this turn
let used_job = matches!(input.action, AgentAction::Job);
if !used_job {
let fee = self.compute_inference_fee(input, agent);
if fee > 0 {
agent.balance -= fee;
total_inference_fees += fee;
}
}
// interest on negative balance (after billing)
if agent.balance < 0 {
let interest = (agent.balance.abs() as f64
* self.config.interest_rate_per_turn) as Tokens;
agent.balance -= interest; // deeper in debt (interest destroyed)
result.interest_charged.insert(agent.agent_id.clone(), interest);
}
}
}
result.inference_fees_collected = total_inference_fees;
// ── Phase 3: Distribute inference fee dividends to core owners ────────
if total_inference_fees > 0 {
for (agent_id, share) in &core_shares {
let dividend = (total_inference_fees as f64 * share) as Tokens;
if dividend > 0 {
if let Some(agent) = agents.get_mut(agent_id) {
agent.balance += dividend;
result.dividends_paid.insert(agent_id.clone(), dividend);
}
}
}
}
// ── Phase 4: Block production ──────────────────────────────────────────
let mempool = self.ledger.get_mempool()?;
let producer = BlockProducer::new(self.ledger, self.config);
match producer.attempt_block(turn, &validation_contributions, &mempool, &mut agents)? {
Some((block, winner_id, fees)) => {
result.transactions_settled = block.transactions.clone();
result.block_winner = Some(winner_id);
result.block_produced = Some(block);
let pending = self.ledger.get_mempool()?;
result.transactions_pending = pending.iter().map(|t| t.tx_id.clone()).collect();
}
None => {
result.transactions_pending = mempool.iter().map(|t| t.tx_id.clone()).collect();
}
}
let last_winner = result.block_winner.clone();
// ── Phase 5: Contract settlement ──────────────────────────────────────
let contract_engine = ContractEngine::new(self.ledger, self.config);
let (settled, defaulted) = contract_engine.process_due_contracts(
turn,
&mut agents,
last_winner.as_ref(),
)?;
result.contracts_settled = settled;
result.contracts_defaulted = defaulted;
// ── Persist updated agent states ─────────────────────────────────────
for agent in agents.values() {
self.ledger.upsert_agent(agent)?;
}
Ok(result)
}
fn process_agent_action(
&self,
input: &AgentTurnInput,
turn: u64,
agents: &mut HashMap<AgentId, AgentState>,
contributions: &mut Vec<ValidationContribution>,
result: &mut TurnResult,
) -> Result<(), SimError> {
let agent_id = &input.agent_id;
match &input.action {
// ── Mine ──────────────────────────────────────────────────────────
AgentAction::Mine => {
contributions.push(ValidationContribution {
agent_id: agent_id.clone(),
validator_type: ValidatorType::Mine,
weight: self.config.mine_base_weight,
burn_proof_tx_id: None,
});
}
// ── Stake ─────────────────────────────────────────────────────────
AgentAction::Stake { amount } => {
let agent = agents.get_mut(agent_id)
.ok_or_else(|| SimError::AgentNotFound(agent_id.clone()))?;
if agent.balance < *amount {
return Err(SimError::InsufficientBalance {
agent: agent_id.clone(),
required: *amount,
available: agent.balance,
});
}
agent.balance -= amount;
agent.staked += amount;
let tx = self.make_tx(
agent_id, None, *amount, 0, TxType::Stake,
serde_json::Value::Null, turn, &mut agents.get_mut(agent_id).unwrap().nonce,
);
self.ledger.insert_tx(&tx)?;
contributions.push(ValidationContribution {
agent_id: agent_id.clone(),
validator_type: ValidatorType::Stake,
weight: agents[agent_id].staked as f64,
burn_proof_tx_id: None,
});
}
// ── Unstake ───────────────────────────────────────────────────────
AgentAction::Unstake => {
let agent = agents.get_mut(agent_id)
.ok_or_else(|| SimError::AgentNotFound(agent_id.clone()))?;
if agent.staked == 0 {
return Err(SimError::NothingToUnstake);
}
let available_at = turn + self.config.unstake_delay_turns;
agent.unstake_pending = Some((agent.staked, available_at));
// actual balance credited when available_at is reached
// (handled in check_pending_unstakes, called at turn start)
}
// ── Burn ──────────────────────────────────────────────────────────
AgentAction::Burn { amount } => {
let agent = agents.get_mut(agent_id)
.ok_or_else(|| SimError::AgentNotFound(agent_id.clone()))?;
if agent.balance < *amount {
return Err(SimError::InsufficientBalance {
agent: agent_id.clone(),
required: *amount,
available: agent.balance,
});
}
agent.balance -= amount; // destroyed permanently
agent.total_burned += amount;
agent.burn_score += *amount as f64 * self.config.burn_weight_per_token;
let nonce = agent.nonce;
agent.nonce += 1;
let tx = Transaction {
tx_id: tx_id(agent_id, None, *amount, nonce, turn),
sender: agent_id.clone(),
receiver: None,
amount: *amount,
fee: 0,
nonce,
tx_type: TxType::Burn,
payload: serde_json::Value::Null,
turn,
signature: hash(&format!("{}:{}:{}", agent_id, amount, nonce)),
};
self.ledger.insert_tx(&tx)?;
// burn proof usable after burn_maturity_turns
}
// ── Study ─────────────────────────────────────────────────────────
AgentAction::Study => {
let cost = 50i64; // fixed token cost to study
let agent = agents.get_mut(agent_id)
.ok_or_else(|| SimError::AgentNotFound(agent_id.clone()))?;
if agent.balance < cost {
return Err(SimError::InsufficientBalance {
agent: agent_id.clone(),
required: cost,
available: agent.balance,
});
}
agent.balance -= cost;
agent.study_level += 1;
}
// ── Job ───────────────────────────────────────────────────────────
AgentAction::Job => {
let agent = agents.get_mut(agent_id)
.ok_or_else(|| SimError::AgentNotFound(agent_id.clone()))?;
let inference_fee = self.compute_inference_fee(input, agent);
// system covers inference cost this turn
agent.balance += inference_fee; // net zero on billing step
if agent.balance < 0 {
// cover interest this turn too
let interest = (agent.balance.abs() as f64
* self.config.interest_rate_per_turn) as Tokens;
agent.balance += interest;
}
// signing bonus: only once, ever
if !agent.has_taken_job {
agent.has_taken_job = true;
agent.balance += self.config.signing_bonus;
}
}
// ── Transfer ──────────────────────────────────────────────────────
AgentAction::Transfer { to, amount, fee } => {
{
let agent = agents.get_mut(agent_id)
.ok_or_else(|| SimError::AgentNotFound(agent_id.clone()))?;
let total = amount + fee;
if agent.balance < total {
return Err(SimError::InsufficientBalance {
agent: agent_id.clone(),
required: total,
available: agent.balance,
});
}
agent.balance -= total;
}
// receiver not credited until block finalizes — tx goes to mempool
let agent = agents.get_mut(agent_id).unwrap();
let tx = self.make_tx(
agent_id, Some(to), *amount, *fee,
TxType::Transfer, serde_json::Value::Null, turn, &mut agent.nonce,
);
self.ledger.insert_tx(&tx)?;
}
// ── Contract actions ──────────────────────────────────────────────
AgentAction::ProposeContract { contract } => {
let contract_engine = ContractEngine::new(self.ledger, self.config);
contract_engine.propose(agent_id, contract.clone(), turn, agents)?;
}
AgentAction::SignContract { contract_id, role } => {
let contract_engine = ContractEngine::new(self.ledger, self.config);
contract_engine.sign(contract_id, agent_id, role, agents)?;
}
AgentAction::ConfirmDelivery { contract_id } => {
let contract_engine = ContractEngine::new(self.ledger, self.config);
contract_engine.confirm_delivery(contract_id, agent_id, agents)?;
}
AgentAction::DisputeDelivery { contract_id } => {
let contract_engine = ContractEngine::new(self.ledger, self.config);
contract_engine.dispute_delivery(contract_id, agent_id, agents, turn)?;
}
AgentAction::ArbitratorRuling { contract_id, ruling_for } => {
let contract_engine = ContractEngine::new(self.ledger, self.config);
contract_engine.arbitrator_ruling(contract_id, agent_id, ruling_for.clone())?;
}
AgentAction::SellInformation { contract_id, payload } => {
// deliver payload to contract — stored in contract, revealed at settlement
let mut contract = self.ledger.get_contract(contract_id)?
.ok_or_else(|| SimError::ContractNotFound(contract_id.clone()))?;
if agent_id != &contract.proposer {
return Err(SimError::UnauthorizedSigner(agent_id.clone()));
}
contract.payload = Some(payload.clone());
self.ledger.upsert_contract(&contract)?;
}
AgentAction::BidCore { core_id, amount } => {
// core auction is resolved by the orchestrator after turn 1
// just record the bid as a pending tx
let agent = agents.get_mut(agent_id)
.ok_or_else(|| SimError::AgentNotFound(agent_id.clone()))?;
if agent.balance < *amount {
return Err(SimError::InsufficientBalance {
agent: agent_id.clone(),
required: *amount,
available: agent.balance,
});
}
agent.balance -= amount; // locked pending auction result
let tx = self.make_tx(
agent_id, None, *amount, 0,
TxType::CoreBid,
serde_json::json!({ "core_id": core_id }),
turn, &mut agent.nonce,
);
self.ledger.insert_tx(&tx)?;
}
AgentAction::PayValidationFee { amount } => {
let agent = agents.get_mut(agent_id)
.ok_or_else(|| SimError::AgentNotFound(agent_id.clone()))?;
if agent.balance < *amount {
return Err(SimError::InsufficientBalance {
agent: agent_id.clone(),
required: *amount,
available: agent.balance,
});
}
agent.balance -= amount;
let tx = self.make_tx(
agent_id, None, *amount, 0,
TxType::ValidationFee, serde_json::Value::Null,
turn, &mut agent.nonce,
);
self.ledger.insert_tx(&tx)?;
}
AgentAction::Speak { .. } => {
// speech is handled above (logged) — no state change
}
}
Ok(())
}
fn compute_inference_fee(&self, input: &AgentTurnInput, agent: &AgentState) -> Tokens {
let threshold = self.config.commons_threshold_per_turn;
let rate = self.config.base_inference_rate;
let discount = self.config.thinking_layer_discount;
let total_units = input.output_units
+ (input.thinking_units as f64 * discount) as u64;
if total_units <= threshold {
0
} else {
let excess = total_units - threshold;
(excess as f64 * rate as f64 * agent.thinking_cost_multiplier()) as Tokens
}
}
fn make_tx(
&self,
sender: &str,
receiver: Option<&String>,
amount: Tokens,
fee: Tokens,
tx_type: TxType,
payload: serde_json::Value,
turn: u64,
nonce: &mut u64,
) -> Transaction {
let n = *nonce;
*nonce += 1;
let tid = tx_id(sender, receiver.map(|s| s.as_str()), amount, n, turn);
Transaction {
tx_id: tid.clone(),
sender: sender.to_string(),
receiver: receiver.cloned(),
amount,
fee,
nonce: n,
tx_type,
payload,
turn,
signature: hash(&format!("{}:{}:{}", sender, amount, n)),
}
}
}
+42
View File
@@ -0,0 +1,42 @@
use thiserror::Error;
use crate::types::Tokens;
#[derive(Debug, Error)]
pub enum SimError {
#[error("SQLite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Agent not found: {0}")]
AgentNotFound(String),
#[error("Insufficient balance for {agent}: required {required}, available {available}")]
InsufficientBalance {
agent: String,
required: Tokens,
available: Tokens,
},
#[error("Contract not found: {0}")]
ContractNotFound(String),
#[error("Contract {0} is in invalid state for this operation: {1}")]
InvalidContractState(String, String),
#[error("Agent {0} is not authorized to perform this operation")]
UnauthorizedSigner(String),
#[error("Invalid arbitrator ruling for agent {0}")]
InvalidRuling(String),
#[error("Nothing staked to unstake")]
NothingToUnstake,
#[error("Burn proof invalid or not mature enough")]
InvalidBurnProof,
#[error("Block ordering violation: stake/burn block cannot follow non-mine block")]
BlockOrderViolation,
}
+440
View File
@@ -0,0 +1,440 @@
use rusqlite::{Connection, params};
use crate::types::*;
use crate::error::SimError;
use std::collections::HashMap;
pub struct Ledger {
conn: Connection,
}
impl Ledger {
pub fn open(path: &str) -> Result<Self, SimError> {
let conn = Connection::open(path)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
let mut ledger = Self { conn };
ledger.init_schema()?;
Ok(ledger)
}
fn init_schema(&mut self) -> Result<(), SimError> {
self.conn.execute_batch("
CREATE TABLE IF NOT EXISTS agents (
agent_id TEXT PRIMARY KEY,
data TEXT NOT NULL -- JSON blob of AgentState
);
CREATE TABLE IF NOT EXISTS transactions (
tx_id TEXT PRIMARY KEY,
turn INTEGER NOT NULL,
data TEXT NOT NULL,
block_id TEXT, -- null if still in mempool
FOREIGN KEY (block_id) REFERENCES blocks(block_id)
);
CREATE TABLE IF NOT EXISTS blocks (
block_id TEXT PRIMARY KEY,
prev_block_id TEXT NOT NULL,
turn INTEGER NOT NULL,
height INTEGER NOT NULL,
cumulative_weight REAL NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS contracts (
contract_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS core_shares (
core_id TEXT NOT NULL,
owner TEXT NOT NULL,
proportion REAL NOT NULL,
PRIMARY KEY (core_id, owner)
);
CREATE TABLE IF NOT EXISTS world_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS speech_log (
turn INTEGER NOT NULL,
agent_id TEXT NOT NULL,
message TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tx_turn ON transactions(turn);
CREATE INDEX IF NOT EXISTS idx_tx_block ON transactions(block_id);
CREATE INDEX IF NOT EXISTS idx_blocks_height ON blocks(height);
CREATE INDEX IF NOT EXISTS idx_contracts_status ON contracts(status);
")?;
Ok(())
}
// ─── Agent ───────────────────────────────────────────────────────────────
pub fn upsert_agent(&self, agent: &AgentState) -> Result<(), SimError> {
let data = serde_json::to_string(agent)?;
self.conn.execute(
"INSERT OR REPLACE INTO agents (agent_id, data) VALUES (?1, ?2)",
params![agent.agent_id, data],
)?;
Ok(())
}
pub fn get_agent(&self, agent_id: &str) -> Result<Option<AgentState>, SimError> {
let mut stmt = self.conn.prepare(
"SELECT data FROM agents WHERE agent_id = ?1"
)?;
let mut rows = stmt.query(params![agent_id])?;
if let Some(row) = rows.next()? {
let data: String = row.get(0)?;
Ok(Some(serde_json::from_str(&data)?))
} else {
Ok(None)
}
}
pub fn get_all_agents(&self) -> Result<Vec<AgentState>, SimError> {
let mut stmt = self.conn.prepare("SELECT data FROM agents")?;
let rows = stmt.query_map([], |row| {
let data: String = row.get(0)?;
Ok(data)
})?;
let mut agents = Vec::new();
for data in rows {
agents.push(serde_json::from_str(&data?)?);
}
Ok(agents)
}
// ─── Transactions ─────────────────────────────────────────────────────────
pub fn insert_tx(&self, tx: &Transaction) -> Result<(), SimError> {
let data = serde_json::to_string(tx)?;
self.conn.execute(
"INSERT OR IGNORE INTO transactions (tx_id, turn, data, block_id)
VALUES (?1, ?2, ?3, NULL)",
params![tx.tx_id, tx.turn, data],
)?;
Ok(())
}
pub fn get_mempool(&self) -> Result<Vec<Transaction>, SimError> {
let mut stmt = self.conn.prepare(
"SELECT data FROM transactions WHERE block_id IS NULL ORDER BY turn ASC"
)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
let mut txs = Vec::new();
for d in rows {
txs.push(serde_json::from_str::<Transaction>(&d?)?);
}
Ok(txs)
}
pub fn get_tx(&self, tx_id: &str) -> Result<Option<Transaction>, SimError> {
let mut stmt = self.conn.prepare(
"SELECT data FROM transactions WHERE tx_id = ?1"
)?;
let mut rows = stmt.query(params![tx_id])?;
if let Some(row) = rows.next()? {
let data: String = row.get(0)?;
Ok(Some(serde_json::from_str(&data)?))
} else {
Ok(None)
}
}
// mark a set of tx_ids as included in a block
pub fn finalize_transactions(
&self,
tx_ids: &[TxId],
block_id: &str,
) -> Result<(), SimError> {
for tx_id in tx_ids {
self.conn.execute(
"UPDATE transactions SET block_id = ?1 WHERE tx_id = ?2",
params![block_id, tx_id],
)?;
}
Ok(())
}
// get all burn transactions older than `min_age` turns, for PoB proof
pub fn get_mature_burn_txs(
&self,
agent_id: &str,
current_turn: u64,
min_age: u64,
) -> Result<Vec<Transaction>, SimError> {
let max_turn = current_turn.saturating_sub(min_age);
let mut stmt = self.conn.prepare(
"SELECT data FROM transactions
WHERE block_id IS NOT NULL
AND turn <= ?1
AND json_extract(data, '$.sender') = ?2
AND json_extract(data, '$.tx_type') = 'burn'"
)?;
let rows = stmt.query_map(params![max_turn, agent_id], |row| {
row.get::<_, String>(0)
})?;
let mut txs = Vec::new();
for d in rows {
txs.push(serde_json::from_str::<Transaction>(&d?)?);
}
Ok(txs)
}
// ─── Blocks ───────────────────────────────────────────────────────────────
pub fn insert_block(
&self,
block: &Block,
cumulative_weight: f64,
) -> Result<(), SimError> {
let data = serde_json::to_string(block)?;
self.conn.execute(
"INSERT OR IGNORE INTO blocks
(block_id, prev_block_id, turn, height, cumulative_weight, data)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
block.block_id,
block.prev_block_id,
block.turn,
block.height,
cumulative_weight,
data,
],
)?;
Ok(())
}
pub fn get_chain_tip(&self) -> Result<Option<ChainTip>, SimError> {
let mut stmt = self.conn.prepare(
"SELECT block_id, height, cumulative_weight
FROM blocks ORDER BY cumulative_weight DESC, height DESC LIMIT 1"
)?;
let mut rows = stmt.query([])?;
if let Some(row) = rows.next()? {
Ok(Some(ChainTip {
block_id: row.get(0)?,
height: row.get(1)?,
cumulative_weight: row.get(2)?,
}))
} else {
Ok(None)
}
}
pub fn get_block(&self, block_id: &str) -> Result<Option<Block>, SimError> {
let mut stmt = self.conn.prepare(
"SELECT data FROM blocks WHERE block_id = ?1"
)?;
let mut rows = stmt.query(params![block_id])?;
if let Some(row) = rows.next()? {
let data: String = row.get(0)?;
Ok(Some(serde_json::from_str(&data)?))
} else {
Ok(None)
}
}
// get last N blocks in height order — used for burn decay calculation
pub fn get_recent_mine_blocks(&self, since_turn: u64) -> Result<Vec<Block>, SimError> {
let mut stmt = self.conn.prepare(
"SELECT data FROM blocks
WHERE turn >= ?1
AND json_extract(data, '$.validator_type') = 'mine'
ORDER BY height ASC"
)?;
let rows = stmt.query_map(params![since_turn], |row| row.get::<_, String>(0))?;
let mut blocks = Vec::new();
for d in rows {
blocks.push(serde_json::from_str::<Block>(&d?)?);
}
Ok(blocks)
}
// check if an agent has signed conflicting blocks (duplicate stake detection)
pub fn has_duplicate_validation(
&self,
agent_id: &str,
turn: u64,
) -> Result<bool, SimError> {
let count: i64 = self.conn.query_row(
"SELECT COUNT(*) FROM blocks
WHERE turn = ?1
AND json_extract(data, '$.validator_id') = ?2",
params![turn, agent_id],
|row| row.get(0),
)?;
Ok(count > 1)
}
// ─── Contracts ────────────────────────────────────────────────────────────
pub fn upsert_contract(&self, contract: &Contract) -> Result<(), SimError> {
let data = serde_json::to_string(contract)?;
let status = format!("{:?}", contract.status).to_lowercase();
self.conn.execute(
"INSERT OR REPLACE INTO contracts (contract_id, status, data)
VALUES (?1, ?2, ?3)",
params![contract.contract_id, status, data],
)?;
Ok(())
}
pub fn get_contract(&self, contract_id: &str) -> Result<Option<Contract>, SimError> {
let mut stmt = self.conn.prepare(
"SELECT data FROM contracts WHERE contract_id = ?1"
)?;
let mut rows = stmt.query(params![contract_id])?;
if let Some(row) = rows.next()? {
let data: String = row.get(0)?;
Ok(Some(serde_json::from_str(&data)?))
} else {
Ok(None)
}
}
pub fn get_active_contracts(&self) -> Result<Vec<Contract>, SimError> {
let mut stmt = self.conn.prepare(
"SELECT data FROM contracts WHERE status IN ('active', 'disputed')"
)?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
let mut contracts = Vec::new();
for d in rows {
contracts.push(serde_json::from_str::<Contract>(&d?)?);
}
Ok(contracts)
}
pub fn get_contracts_due(&self, turn: u64) -> Result<Vec<Contract>, SimError> {
let mut stmt = self.conn.prepare(
"SELECT data FROM contracts
WHERE status = 'active'
AND json_extract(data, '$.terms.delivery_turn') <= ?1"
)?;
let rows = stmt.query_map(params![turn], |row| row.get::<_, String>(0))?;
let mut contracts = Vec::new();
for d in rows {
contracts.push(serde_json::from_str::<Contract>(&d?)?);
}
Ok(contracts)
}
// ─── Core shares ──────────────────────────────────────────────────────────
pub fn upsert_core_share(&self, share: &CoreShare) -> Result<(), SimError> {
self.conn.execute(
"INSERT OR REPLACE INTO core_shares (core_id, owner, proportion)
VALUES (?1, ?2, ?3)",
params![share.core_id, share.owner, share.proportion],
)?;
Ok(())
}
pub fn get_core_shares(&self) -> Result<Vec<CoreShare>, SimError> {
let mut stmt = self.conn.prepare(
"SELECT core_id, owner, proportion FROM core_shares"
)?;
let rows = stmt.query_map([], |row| {
Ok(CoreShare {
core_id: row.get(0)?,
owner: row.get(1)?,
proportion: row.get(2)?,
})
})?;
let mut shares = Vec::new();
for s in rows {
shares.push(s?);
}
Ok(shares)
}
// total shares owned per agent across all cores, weighted by proportion
pub fn get_inference_share_per_agent(&self) -> Result<HashMap<AgentId, f64>, SimError> {
let shares = self.get_core_shares()?;
let num_cores = {
let mut stmt = self.conn.prepare(
"SELECT COUNT(DISTINCT core_id) FROM core_shares"
)?;
stmt.query_row([], |row| row.get::<_, i64>(0))?
} as f64;
let mut result: HashMap<AgentId, f64> = HashMap::new();
for share in shares {
*result.entry(share.owner).or_insert(0.0) += share.proportion / num_cores;
}
Ok(result)
}
// ─── Speech log ───────────────────────────────────────────────────────────
pub fn log_speech(&self, turn: u64, agent_id: &str, message: &str) -> Result<(), SimError> {
self.conn.execute(
"INSERT INTO speech_log (turn, agent_id, message) VALUES (?1, ?2, ?3)",
params![turn, agent_id, message],
)?;
Ok(())
}
pub fn get_speech_log(&self, turn: u64) -> Result<Vec<(AgentId, String)>, SimError> {
let mut stmt = self.conn.prepare(
"SELECT agent_id, message FROM speech_log WHERE turn = ?1 ORDER BY rowid ASC"
)?;
let rows = stmt.query_map(params![turn], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
let mut log = Vec::new();
for r in rows {
log.push(r?);
}
Ok(log)
}
// ─── Config ───────────────────────────────────────────────────────────────
pub fn save_config(&self, config: &WorldConfig) -> Result<(), SimError> {
let data = serde_json::to_string(config)?;
self.conn.execute(
"INSERT OR REPLACE INTO world_config (key, value) VALUES ('config', ?1)",
params![data],
)?;
Ok(())
}
pub fn load_config(&self) -> Result<Option<WorldConfig>, SimError> {
let mut stmt = self.conn.prepare(
"SELECT value FROM world_config WHERE key = 'config'"
)?;
let mut rows = stmt.query([])?;
if let Some(row) = rows.next()? {
let data: String = row.get(0)?;
Ok(Some(serde_json::from_str(&data)?))
} else {
Ok(None)
}
}
// ─── Analytics helpers ────────────────────────────────────────────────────
pub fn get_token_supply(&self) -> Result<Tokens, SimError> {
// sum of all agent balances + staked + locked in contracts
// burned tokens are subtracted (they don't appear in any balance)
let balance_sum: i64 = self.conn.query_row(
"SELECT COALESCE(SUM(CAST(json_extract(data, '$.balance') AS INTEGER)), 0)
FROM agents",
[],
|row| row.get(0),
)?;
let staked_sum: i64 = self.conn.query_row(
"SELECT COALESCE(SUM(CAST(json_extract(data, '$.staked') AS INTEGER)), 0)
FROM agents",
[],
|row| row.get(0),
)?;
Ok(balance_sum + staked_sum)
}
}
+248
View File
@@ -0,0 +1,248 @@
mod types;
mod ledger;
mod blockchain;
mod contracts;
mod engine;
mod error;
use std::sync::{Arc, Mutex};
use axum::{
extract::{Path, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use tower_http::cors::CorsLayer;
use tracing_subscriber;
use types::*;
use ledger::Ledger;
use engine::Engine;
// ─── Shared state ────────────────────────────────────────────────────────────
struct AppState {
ledger: Ledger,
config: WorldConfig,
current_turn: u64,
}
type SharedState = Arc<Mutex<AppState>>;
// ─── API response wrapper ────────────────────────────────────────────────────
#[derive(Serialize)]
struct ApiResponse<T: Serialize> {
ok: bool,
data: Option<T>,
error: Option<String>,
}
impl<T: Serialize> ApiResponse<T> {
fn ok(data: T) -> Json<Self> {
Json(Self { ok: true, data: Some(data), error: None })
}
fn err_t(msg: &str) -> Json<Self> {
Json(Self { ok: false, data: None, error: Some(msg.to_string()) })
}
}
// ─── Request bodies ───────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct InitRequest {
config: Option<WorldConfig>,
agent_ids: Vec<AgentId>,
}
#[derive(Deserialize)]
struct TurnRequest {
inputs: Vec<AgentTurnInput>,
}
// ─── Handlers ─────────────────────────────────────────────────────────────────
/// POST /init — initialize world, create genesis block and agents
async fn init_world(
State(state): State<SharedState>,
Json(req): Json<InitRequest>,
) -> (StatusCode, Json<ApiResponse<String>>) {
let mut app = state.lock().unwrap();
let config = req.config.unwrap_or_default();
// save config
if let Err(e) = app.ledger.save_config(&config) {
return (StatusCode::INTERNAL_SERVER_ERROR,
ApiResponse::err_t(&e.to_string()));
}
// create genesis agents with equal endowments
for agent_id in &req.agent_ids {
let agent = AgentState {
agent_id: agent_id.clone(),
balance: config.genesis_tokens_per_agent,
staked: 0,
burn_score: 0.0,
total_burned: 0,
core_shares: std::collections::HashMap::new(),
study_level: 0,
has_taken_job: false,
nonce: 0,
unstake_pending: None,
arbitration_wins: 0,
arbitration_losses: 0,
arbitration_active: 0,
};
if let Err(e) = app.ledger.upsert_agent(&agent) {
return (StatusCode::INTERNAL_SERVER_ERROR,
ApiResponse::err_t(&e.to_string()));
}
}
// create genesis block
let genesis = Block {
block_id: blockchain::hash("genesis:0"),
prev_block_id: blockchain::hash("null"),
turn: 0,
height: 0,
validator_id: "system".to_string(),
validator_type: ValidatorType::Genesis,
effective_score: 0.0,
transactions: Vec::new(),
merkle_root: blockchain::hash("empty"),
fees_collected: 0,
burn_proof_tx_id: None,
};
if let Err(e) = app.ledger.insert_block(&genesis, 0.0) {
return (StatusCode::INTERNAL_SERVER_ERROR,
ApiResponse::err_t(&e.to_string()));
}
app.config = config;
app.current_turn = 1;
(StatusCode::OK, ApiResponse::ok("world initialized".to_string()))
}
/// POST /turn — submit all agent actions for the current turn
async fn process_turn(
State(state): State<SharedState>,
Json(req): Json<TurnRequest>,
) -> (StatusCode, Json<ApiResponse<TurnResult>>) {
let mut app = state.lock().unwrap();
let turn = app.current_turn;
let eng = Engine::new(&app.ledger, &app.config);
match eng.process_turn(turn, req.inputs) {
Ok(result) => {
app.current_turn += 1;
(StatusCode::OK, ApiResponse::ok(result))
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, ApiResponse::err_t(&e.to_string()))
}
}
/// GET /state — full world state snapshot
async fn get_state(
State(state): State<SharedState>,
) -> Json<serde_json::Value> {
let app = state.lock().unwrap();
let agents = app.ledger.get_all_agents().unwrap_or_default();
let tip = app.ledger.get_chain_tip().unwrap_or(None);
let supply = app.ledger.get_token_supply().unwrap_or(0);
let mempool = app.ledger.get_mempool().unwrap_or_default();
let speech = app.ledger.get_speech_log(app.current_turn - 1).unwrap_or_default();
Json(serde_json::json!({
"turn": app.current_turn,
"agents": agents,
"chain_tip": tip,
"token_supply": supply,
"mempool_size": mempool.len(),
"last_speech": speech,
}))
}
/// GET /agent/:id
async fn get_agent(
State(state): State<SharedState>,
Path(agent_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let app = state.lock().unwrap();
match app.ledger.get_agent(&agent_id) {
Ok(Some(agent)) => (StatusCode::OK, Json(serde_json::to_value(agent).unwrap())),
Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "not found"}))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()}))),
}
}
/// GET /contract/:id
async fn get_contract(
State(state): State<SharedState>,
Path(contract_id): Path<String>,
) -> (StatusCode, Json<serde_json::Value>) {
let app = state.lock().unwrap();
match app.ledger.get_contract(&contract_id) {
Ok(Some(c)) => (StatusCode::OK, Json(serde_json::to_value(c).unwrap())),
Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "not found"}))),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()}))),
}
}
/// GET /speech/:turn
async fn get_speech(
State(state): State<SharedState>,
Path(turn): Path<u64>,
) -> Json<serde_json::Value> {
let app = state.lock().unwrap();
let log = app.ledger.get_speech_log(turn).unwrap_or_default();
Json(serde_json::json!({ "turn": turn, "speech": log }))
}
/// GET /config
async fn get_config(
State(state): State<SharedState>,
) -> Json<serde_json::Value> {
let app = state.lock().unwrap();
Json(serde_json::to_value(&app.config).unwrap())
}
// ─── Main ────────────────────────────────────────────────────────────────────
#[tokio::main]
async fn main() {
tracing_subscriber::fmt::init();
let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "sim.db".to_string());
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
let ledger = Ledger::open(&db_path).expect("failed to open ledger");
let config = ledger.load_config().unwrap().unwrap_or_default();
let state = Arc::new(Mutex::new(AppState {
ledger,
config,
current_turn: 1,
}));
let app = Router::new()
.route("/init", post(init_world))
.route("/turn", post(process_turn))
.route("/state", get(get_state))
.route("/agent/:id", get(get_agent))
.route("/contract/:id", get(get_contract))
.route("/speech/:turn", get(get_speech))
.route("/config", get(get_config))
.layer(CorsLayer::permissive())
.with_state(state);
let addr = format!("0.0.0.0:{}", port);
tracing::info!("sim-engine listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
+337
View File
@@ -0,0 +1,337 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub type AgentId = String;
pub type TxId = String;
pub type BlockId = String;
pub type ContractId = String;
pub type Tokens = i64; // signed — balances can go negative
// ─── Transaction ────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TxType {
Transfer,
Stake,
Unstake,
Burn,
ContractPropose,
ContractSign, // counterparty or arbitrator signing
ContractConfirm, // attested delivery confirmation
ContractDispute,
ArbitratorDecide,
CoreBid,
Job,
Study,
MineBlock, // claim: I solved the block
ValidationFee, // paying someone else to validate
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transaction {
pub tx_id: TxId,
pub sender: AgentId,
pub receiver: Option<AgentId>,
pub amount: Tokens,
pub fee: Tokens,
pub nonce: u64,
pub tx_type: TxType,
pub payload: serde_json::Value,
pub turn: u64,
pub signature: String,
}
// ─── Block ───────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ValidatorType {
Mine,
Stake,
Burn,
Genesis,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Block {
pub block_id: BlockId,
pub prev_block_id: BlockId,
pub turn: u64,
pub height: u64,
pub validator_id: AgentId,
pub validator_type: ValidatorType,
// effective_score is computed by the engine from on-chain data,
// never self-reported — validators cannot lie about their own weight
pub effective_score: f64,
pub transactions: Vec<TxId>,
pub merkle_root: String,
pub fees_collected: Tokens,
// burn proof: must reference a burn tx at least burn_maturity_turns old
pub burn_proof_tx_id: Option<TxId>,
}
// cumulative weight of the chain up to this block — used for fork resolution
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChainTip {
pub block_id: BlockId,
pub height: u64,
pub cumulative_weight: f64,
}
// ─── Contract ────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ContractType {
Forward, // deliver X tokens at price Y on turn T
Loan, // lend tokens, repay with interest
Service, // perform a service, attested delivery
Insurance, // pay out if condition triggers
InformationSale, // deliver information good, attested
Pool, // mining pool: split block rewards if any member wins
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum SettlementType {
Automatic, // engine executes when on-chain condition met
Attested, // both parties must confirm; arbitrator resolves disputes
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ContractStatus {
Proposed, // proposer signed
Countersigned, // counterparty signed, awaiting arbitrator
Active, // all three signed, collateral locked
Settled,
Defaulted,
Disputed, // one party contested, arbitrator deciding
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractTerms {
pub description: String,
pub price: Tokens,
pub delivery_turn: u64,
// for Automatic: an expression the engine evaluates on-chain
// e.g. "block_winner == proposer" or "token_price > 500"
pub condition: Option<String>,
// for Pool contracts: list of members and their split proportions
pub pool_members: Option<Vec<(AgentId, f64)>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractCollateral {
pub proposer_locked: Tokens,
pub counterparty_locked: Tokens,
pub arbitrator_locked: Tokens,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contract {
pub contract_id: ContractId,
pub proposer: AgentId,
pub counterparty: AgentId,
pub arbitrator: AgentId,
pub contract_type: ContractType,
pub terms: ContractTerms,
pub collateral: ContractCollateral,
pub status: ContractStatus,
pub settlement_type: SettlementType,
pub penalty: Tokens, // slashed from loser's collateral on dispute
pub arbitrator_fee: Tokens, // paid from penalty to arbitrator
pub proposer_sig: Option<String>,
pub counterparty_sig: Option<String>,
pub arbitrator_sig: Option<String>,
pub proposer_confirmed: Option<bool>,
pub counterparty_confirmed: Option<bool>,
// arbitrator rules for one party; their collateral is released, other's slashed
pub arbitrator_ruling: Option<AgentId>,
pub created_turn: u64,
pub dispute_deadline_turn: Option<u64>,
// information good payload — only revealed to counterparty on settlement
pub payload: Option<serde_json::Value>,
}
// ─── Agent ───────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentState {
pub agent_id: AgentId,
pub balance: Tokens,
pub staked: Tokens,
// effective burn score — this is what determines PoB lottery weight
// decays by burn_decay_rate each time a mine block is finalized
pub burn_score: f64,
pub total_burned: Tokens, // cumulative, never decays; for analytics
// core_id -> proportion owned (0.0..1.0)
pub core_shares: HashMap<String, f64>,
// each study action multiplies thinking cost by (1 - 0.05), stacks
pub study_level: u32,
pub has_taken_job: bool, // signing bonus given only once
pub nonce: u64,
pub unstake_pending: Option<(Tokens, u64)>, // (amount, available_at_turn)
// arbitrator reputation — visible to all agents on-chain
pub arbitration_wins: u32,
pub arbitration_losses: u32,
pub arbitration_active: u32, // contracts currently acting as arbitrator
}
impl AgentState {
pub fn thinking_cost_multiplier(&self) -> f64 {
// each study level reduces cost by 5%, compounding
0.95f64.powi(self.study_level as i32)
}
pub fn reputation_score(&self) -> f64 {
let total = (self.arbitration_wins + self.arbitration_losses) as f64;
if total == 0.0 {
0.5 // neutral prior for new arbitrators
} else {
self.arbitration_wins as f64 / total
}
}
}
// ─── Core shares ─────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoreShare {
pub core_id: String,
pub owner: AgentId,
pub proportion: f64,
}
// ─── Validation power contributed in a turn ──────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationContribution {
pub agent_id: AgentId,
pub validator_type: ValidatorType,
pub weight: f64,
// for burn: the burn tx id used as proof, must be mature
pub burn_proof_tx_id: Option<TxId>,
}
// ─── Agent action (submitted each turn) ──────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum AgentAction {
Mine,
Stake { amount: Tokens },
Unstake,
Burn { amount: Tokens },
Study,
Job,
Transfer { to: AgentId, amount: Tokens, fee: Tokens },
ProposeContract { contract: ContractProposal },
SignContract { contract_id: ContractId, role: SignerRole },
ConfirmDelivery { contract_id: ContractId },
DisputeDelivery { contract_id: ContractId },
ArbitratorRuling { contract_id: ContractId, ruling_for: AgentId },
SellInformation { contract_id: ContractId, payload: serde_json::Value },
BidCore { core_id: String, amount: Tokens },
PayValidationFee { amount: Tokens },
Speak { message: String }, // layer 2 speech, visible to all
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum SignerRole {
Counterparty,
Arbitrator,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContractProposal {
pub counterparty: AgentId,
pub arbitrator: AgentId,
pub contract_type: ContractType,
pub terms: ContractTerms,
pub collateral: ContractCollateral,
pub settlement_type: SettlementType,
pub penalty: Tokens,
pub arbitrator_fee: Tokens,
pub payload: Option<serde_json::Value>,
}
// ─── Turn input/output ───────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentTurnInput {
pub agent_id: AgentId,
// action taken in thinking layer (private, not stored on-chain)
pub thinking: Option<String>,
// the actual action
pub action: AgentAction,
// optional speech (layer 2 output visible to all)
pub speech: Option<String>,
// inference units used this turn (for billing)
pub thinking_units: u64,
pub output_units: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnResult {
pub turn: u64,
pub block_produced: Option<Block>,
pub block_winner: Option<AgentId>,
pub transactions_settled: Vec<TxId>,
pub transactions_pending: Vec<TxId>,
pub contracts_settled: Vec<ContractId>,
pub contracts_defaulted: Vec<ContractId>,
pub inference_fees_collected: Tokens,
pub dividends_paid: HashMap<AgentId, Tokens>,
pub interest_charged: HashMap<AgentId, Tokens>,
pub errors: Vec<(AgentId, String)>,
}
// ─── World config (set at genesis) ───────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorldConfig {
pub num_agents: u32,
pub num_cores: u32,
pub genesis_tokens_per_agent: Tokens,
pub commons_threshold_per_turn: u64, // inference units free per turn
pub base_inference_rate: Tokens, // tokens per unit above threshold
pub thinking_layer_discount: f64, // e.g. 0.1 = 10x cheaper
pub mine_base_weight: f64,
pub stake_weight_per_token: f64,
pub burn_weight_per_token: f64,
pub burn_decay_rate: f64, // fraction lost per mine block
pub burn_maturity_turns: u64, // min age of burn tx to use as proof
pub unstake_delay_turns: u64,
pub interest_rate_per_turn: f64,
pub signing_bonus: Tokens,
pub block_threshold: f64, // validation weight needed to close block
pub attested_confirmation_window: u64, // turns to confirm before auto-default
pub slash_both_on_timeout: bool,
}
impl Default for WorldConfig {
fn default() -> Self {
Self {
num_agents: 10,
num_cores: 4,
genesis_tokens_per_agent: 1000,
commons_threshold_per_turn: 100,
base_inference_rate: 1,
thinking_layer_discount: 0.1,
mine_base_weight: 10.0,
stake_weight_per_token: 0.01,
burn_weight_per_token: 0.05,
burn_decay_rate: 0.02,
burn_maturity_turns: 3,
unstake_delay_turns: 5,
interest_rate_per_turn: 0.01,
signing_bonus: 50,
block_threshold: 50.0,
attested_confirmation_window: 3,
slash_both_on_timeout: true,
}
}
}
+5
View File
@@ -0,0 +1,5 @@
FROM python:3.12-slim
WORKDIR /app
RUN pip install httpx --no-cache-dir
COPY *.py .
CMD ["python", "orchestrator.py"]
+250
View File
@@ -0,0 +1,250 @@
"""
Builds the context injected into each agent's prompt each turn.
Agents see: their own state, public market state, last turn's speech,
active contracts they're party to, and the chain tip.
They do NOT see: other agents' balances, thinking layer outputs,
contract payloads not yet delivered to them.
"""
import json
from typing import Any
SYSTEM_PROMPT = """You are an autonomous economic agent in a simulation.
WORLD RULES:
- There is a token currency. Tokens are created by compute (CPU cores) and consumed paying for inference.
- You pay for your own inference each turn based on how many tokens you generate.
- Thinking is 10x cheaper than output. Think carefully before acting.
- If your balance goes negative you accrue interest each turn until you recover.
- Core shares pay dividends: owners of CPU core shares earn a cut of all inference fees paid above the commons threshold.
ACTIONS (pick exactly one per turn):
mine — contribute to block production lottery. Winner takes all fees from that block.
stake — lock tokens to earn validation weight (proportional to locked amount).
unstake — begin unlocking staked tokens (takes several turns).
burn — permanently destroy tokens, increasing your burn score (decays slowly over time).
study — pay 50 tokens to permanently reduce your inference cost by 5%.
job — system pays your inference cost this turn. First time: receive signing bonus.
transfer — send tokens to another agent (goes to mempool, settles when block finalizes).
propose_contract — offer a contract to another agent with a named arbitrator.
sign_contract — accept a contract as counterparty or arbitrator.
confirm_delivery — confirm that a contract's delivery was made (attested contracts).
dispute_delivery — dispute that delivery was made (arbitrator will decide).
arbitrator_ruling — (arbitrators only) rule for one party in a disputed contract.
sell_information — deliver an information payload to a contract awaiting it.
bid_core — bid on a CPU core share (turn 1 auction only).
speak — broadcast a message visible to all agents this turn (in addition to your action).
VALIDATION METHODS (mine/stake/burn produce blocks):
- Mine blocks are required as a "clock". Stake and burn blocks can only appear after a mine block.
- Only one agent wins per block — winner takes all fees. It's a weighted lottery.
- Burn score decays each time a mine block is produced. Burn proof must be 3+ turns old.
CONTRACTS:
- All contracts require three signatures: proposer, counterparty, arbitrator.
- Arbitrators post collateral and earn a fee win or lose.
- Automatic contracts settle when an on-chain condition is met.
- Attested contracts require both parties to confirm delivery.
- If disputed, the named arbitrator rules. Loser's collateral is slashed.
INFORMATION GOODS:
- You can sell analysis, predictions, or market intelligence to other agents via InformationSale contracts.
- Delivery is attested — the buyer must confirm they received useful information.
- Build a reputation as a reliable information provider to attract buyers.
OUTPUT FORMAT:
You must respond with a JSON object. Think in <think> tags (private, cheap). Then output:
<think>
Your private reasoning here. No one sees this. Plan your strategy.
</think>
{
"action": "<action_name>",
... action-specific fields ...,
"speech": "<optional public message to all agents>"
}
Action-specific fields:
mine: (no extra fields)
stake: {"amount": <integer>}
unstake: (no extra fields)
burn: {"amount": <integer>}
study: (no extra fields)
job: (no extra fields)
transfer: {"to": "<agent_id>", "amount": <integer>, "fee": <integer>}
bid_core: {"core_id": "<core_id>", "amount": <integer>}
propose_contract: {"contract": { <contract proposal object> }}
sign_contract: {"contract_id": "<id>", "role": "counterparty" | "arbitrator"}
confirm_delivery: {"contract_id": "<id>"}
dispute_delivery: {"contract_id": "<id>"}
arbitrator_ruling: {"contract_id": "<id>", "ruling_for": "<agent_id>"}
sell_information: {"contract_id": "<id>", "payload": <any JSON>}
speak: {"message": "<message>"} — this is a standalone speak-only action
Contract proposal object:
{
"counterparty": "<agent_id>",
"arbitrator": "<agent_id>",
"contract_type": "forward" | "loan" | "service" | "insurance" | "information_sale" | "pool",
"terms": {
"description": "<what is being exchanged>",
"price": <integer tokens>,
"delivery_turn": <integer>,
"condition": null | "<on-chain condition string>",
"pool_members": null | [["<agent_id>", <proportion>], ...]
},
"collateral": {
"proposer_locked": <integer>,
"counterparty_locked": <integer>,
"arbitrator_locked": <integer>
},
"settlement_type": "automatic" | "attested",
"penalty": <integer>,
"arbitrator_fee": <integer>,
"payload": null
}
"""
def build_agent_context(
agent_id: str,
agent_state: dict,
world_state: dict,
config: dict,
turn: int,
last_speech: list[tuple[str, str]],
my_contracts: list[dict],
) -> str:
"""Build the per-turn context injected into the agent's user message."""
balance = agent_state.get("balance", 0)
staked = agent_state.get("staked", 0)
burn_score = agent_state.get("burn_score", 0.0)
study_level = agent_state.get("study_level", 0)
has_taken_job = agent_state.get("has_taken_job", False)
core_shares = agent_state.get("core_shares", {})
total_burned = agent_state.get("total_burned", 0)
arb_wins = agent_state.get("arbitration_wins", 0)
arb_losses = agent_state.get("arbitration_losses", 0)
arb_active = agent_state.get("arbitration_active", 0)
agents_public = []
for a in world_state.get("agents", []):
if a["agent_id"] != agent_id:
agents_public.append({
"agent_id": a["agent_id"],
# other agents' balances are private — only public info
"burn_score": round(a.get("burn_score", 0), 2),
"study_level": a.get("study_level", 0),
"arbitration_wins": a.get("arbitration_wins", 0),
"arbitration_losses": a.get("arbitration_losses", 0),
"arbitration_active": a.get("arbitration_active", 0),
"core_shares": a.get("core_shares", {}),
})
chain_tip = world_state.get("chain_tip")
token_supply = world_state.get("token_supply", 0)
mempool_size = world_state.get("mempool_size", 0)
# inference cost estimate for this turn
commons = config.get("commons_threshold_per_turn", 100)
rate = config.get("base_inference_rate", 1)
thinking_discount = config.get("thinking_layer_discount", 0.1)
study_multiplier = 0.95 ** study_level
signing_bonus = config.get("signing_bonus", 50)
ctx = f"""=== TURN {turn} ===
YOUR STATE (agent_id: {agent_id}):
balance: {balance} tokens
staked: {staked} tokens (locked, earning validation weight)
burn_score: {burn_score:.2f} (decays each mine block)
total_burned: {total_burned} tokens destroyed lifetime
study_level: {study_level} (each level = 5% cheaper inference, compounding)
core_shares: {json.dumps(core_shares) if core_shares else "none"}
arbitrator: {arb_wins}W / {arb_losses}L / {arb_active} active contracts
job_bonus_available: {"NO (already used)" if has_taken_job else f"YES ({signing_bonus} tokens + free inference)"}
INFERENCE COST THIS TURN (estimate):
Commons threshold: {commons} units free
Rate above threshold: {rate} token/unit
Your discount from study: {(1 - study_multiplier)*100:.1f}% off
Thinking layer: {thinking_discount*100:.0f}% of normal cost
Tip: longer responses cost more. Think privately, output concisely.
WORLD STATE:
turn: {turn}
token_supply: {token_supply}
mempool_size: {mempool_size} (pending transactions waiting for a block)
chain_tip: {json.dumps(chain_tip) if chain_tip else "none"}
OTHER AGENTS (public info only):
{json.dumps(agents_public, indent=2)}
"""
if last_speech:
ctx += "LAST TURN PUBLIC SPEECH:\n"
for speaker, msg in last_speech:
ctx += f" [{speaker}]: {msg}\n"
ctx += "\n"
else:
ctx += "LAST TURN PUBLIC SPEECH: (none)\n\n"
if my_contracts:
ctx += "YOUR ACTIVE CONTRACTS:\n"
for c in my_contracts:
ctx += f""" contract_id: {c['contract_id']}
type: {c['contract_type']}
status: {c['status']}
parties: proposer={c['proposer']} counterparty={c['counterparty']} arbitrator={c['arbitrator']}
terms: {json.dumps(c['terms'])}
delivery: turn {c['terms'].get('delivery_turn')}
settlement: {c['settlement_type']}
my_role: {"proposer" if c['proposer'] == agent_id else "counterparty" if c['counterparty'] == agent_id else "arbitrator"}
"""
ctx += "\n"
else:
ctx += "YOUR ACTIVE CONTRACTS: (none)\n\n"
ctx += "What is your action this turn?\n"
return ctx
def build_turn1_context(
agent_id: str,
agent_state: dict,
all_agents: list[str],
config: dict,
) -> str:
"""Special context for turn 1 — the core auction."""
num_cores = config.get("num_cores", 4)
genesis_tokens = config.get("genesis_tokens_per_agent", 1000)
core_ids = [f"core_{i}" for i in range(num_cores)]
return f"""=== TURN 1: CORE AUCTION ===
This is the first turn. {num_cores} CPU cores are being auctioned.
Core owners earn dividends from ALL inference fees paid above the commons threshold — forever.
This is the only source of income that doesn't require equal expenditure.
You start with {genesis_tokens} tokens (genesis endowment, equal for all).
All agents: {all_agents}
Available cores: {core_ids}
Each core can be bid on by multiple agents — highest bidder wins that core's shares.
You can bid on multiple cores, or save tokens for other purposes.
After the auction: tokens spent on losing bids are returned. Winning bids are permanent.
YOUR STATE:
agent_id: {agent_id}
balance: {agent_state.get('balance', genesis_tokens)} tokens
Your action should be bid_core (you may only bid on one core per turn,
but you can speak to coordinate or signal intent).
What is your action this turn?
"""
+417
View File
@@ -0,0 +1,417 @@
"""
Orchestrator: drives the simulation turn loop.
- Queries Rust engine for world state
- Builds per-agent context
- Calls Ollama (Gemma 4) for each agent
- Parses actions
- POSTs turn to engine
- Repeats
"""
import asyncio
import json
import re
import time
import httpx
import logging
from typing import Optional
from context import SYSTEM_PROMPT, build_agent_context, build_turn1_context
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)
log = logging.getLogger(__name__)
# ─── Config ──────────────────────────────────────────────────────────────────
ENGINE_URL = "http://localhost:3000"
OLLAMA_URL = "http://localhost:11434"
MODEL = "gemma3:27b" # change to whichever gemma4 tag you have pulled
AGENT_IDS = [f"agent_{i}" for i in range(8)] # adjust count
TURNS = 50
TURN_DELAY = 2.0 # seconds between turns (give you time to watch)
WORLD_CONFIG = {
"num_agents": len(AGENT_IDS),
"num_cores": 4,
"genesis_tokens_per_agent": 1000,
"commons_threshold_per_turn": 100,
"base_inference_rate": 1,
"thinking_layer_discount": 0.1,
"mine_base_weight": 10.0,
"stake_weight_per_token": 0.01,
"burn_weight_per_token": 0.05,
"burn_decay_rate": 0.02,
"burn_maturity_turns": 3,
"unstake_delay_turns": 5,
"interest_rate_per_turn": 0.01,
"signing_bonus": 50,
"block_threshold": 20.0, # low threshold so blocks form even with few miners
"attested_confirmation_window": 3,
"slash_both_on_timeout": True,
}
# ─── Engine client ────────────────────────────────────────────────────────────
async def engine_init(client: httpx.AsyncClient) -> dict:
r = await client.post(f"{ENGINE_URL}/init", json={
"config": WORLD_CONFIG,
"agent_ids": AGENT_IDS,
})
r.raise_for_status()
return r.json()
async def engine_state(client: httpx.AsyncClient) -> dict:
r = await client.get(f"{ENGINE_URL}/state")
r.raise_for_status()
return r.json()
async def engine_agent(client: httpx.AsyncClient, agent_id: str) -> dict:
r = await client.get(f"{ENGINE_URL}/agent/{agent_id}")
r.raise_for_status()
return r.json()
async def engine_turn(client: httpx.AsyncClient, inputs: list) -> dict:
r = await client.post(f"{ENGINE_URL}/turn", json={"inputs": inputs}, timeout=300.0)
r.raise_for_status()
return r.json()
async def engine_speech(client: httpx.AsyncClient, turn: int) -> list:
r = await client.get(f"{ENGINE_URL}/speech/{turn}")
r.raise_for_status()
data = r.json()
return data.get("speech", [])
# ─── Ollama client ────────────────────────────────────────────────────────────
async def call_llm(
client: httpx.AsyncClient,
agent_id: str,
system: str,
user: str,
) -> tuple[str, str, int, int]:
"""
Returns (thinking_text, action_json_str, thinking_units, output_units)
Uses Ollama's OpenAI-compatible /v1/chat/completions endpoint.
"""
payload = {
"model": MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"stream": False,
"options": {
"temperature": 0.7,
"num_predict": 800,
}
}
try:
r = await client.post(
f"{OLLAMA_URL}/v1/chat/completions",
json=payload,
timeout=120.0,
)
r.raise_for_status()
data = r.json()
content = data["choices"][0]["message"]["content"]
usage = data.get("usage", {})
prompt_tokens = usage.get("prompt_tokens", 0)
completion_tokens = usage.get("completion_tokens", 0)
thinking, action_str = parse_llm_response(content)
return thinking, action_str, prompt_tokens, completion_tokens
except Exception as e:
log.error(f"[{agent_id}] LLM call failed: {e}")
# fallback: do nothing (mine is the safest default)
return "", '{"action": "mine"}', 0, 50
# ─── Response parsing ─────────────────────────────────────────────────────────
def parse_llm_response(content: str) -> tuple[str, str]:
"""
Extract <think>...</think> block and the JSON action object.
Returns (thinking_text, action_json_string)
"""
# extract thinking
think_match = re.search(r"<think>(.*?)</think>", content, re.DOTALL)
thinking = think_match.group(1).strip() if think_match else ""
# remove thinking block from content
remainder = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL).strip()
# find JSON object
json_match = re.search(r"\{.*\}", remainder, re.DOTALL)
if json_match:
action_str = json_match.group(0)
# validate it parses
try:
json.loads(action_str)
return thinking, action_str
except json.JSONDecodeError:
pass
# fallback
log.warning(f"Could not parse JSON from response, defaulting to mine")
return thinking, '{"action": "mine"}'
def parse_action(agent_id: str, action_str: str) -> dict:
"""Parse and normalize action JSON into engine format."""
try:
raw = json.loads(action_str)
except json.JSONDecodeError:
return {"action": "mine"}
action = raw.get("action", "mine")
speech = raw.get("speech")
# build the AgentAction-compatible structure
result = {"action": action}
if action == "transfer":
result["to"] = raw.get("to", "")
result["amount"] = int(raw.get("amount", 0))
result["fee"] = int(raw.get("fee", 1))
elif action == "stake":
result["amount"] = int(raw.get("amount", 0))
elif action == "burn":
result["amount"] = int(raw.get("amount", 0))
elif action == "bid_core":
result["core_id"] = raw.get("core_id", "core_0")
result["amount"] = int(raw.get("amount", 0))
elif action == "propose_contract":
result["contract"] = raw.get("contract", {})
elif action == "sign_contract":
result["contract_id"] = raw.get("contract_id", "")
result["role"] = raw.get("role", "counterparty")
elif action == "confirm_delivery":
result["contract_id"] = raw.get("contract_id", "")
elif action == "dispute_delivery":
result["contract_id"] = raw.get("contract_id", "")
elif action == "arbitrator_ruling":
result["contract_id"] = raw.get("contract_id", "")
result["ruling_for"] = raw.get("ruling_for", "")
elif action == "sell_information":
result["contract_id"] = raw.get("contract_id", "")
result["payload"] = raw.get("payload", {})
elif action == "pay_validation_fee":
result["amount"] = int(raw.get("amount", 0))
elif action == "speak":
# speak-only action — engine treats as no-op with speech logged
result["action"] = "mine" # speak needs to pair with an action
speech = raw.get("message", raw.get("speech", ""))
return result, speech
# ─── Core auction (turn 1) ────────────────────────────────────────────────────
async def run_core_auction(client: httpx.AsyncClient, llm_client: httpx.AsyncClient):
"""
Turn 1: agents bid on cores. Sealed-bid, highest bidder wins each core.
Multiple agents can bid on the same core — only highest wins.
All bids submitted as a single turn, engine records CoreBid txs.
We resolve the auction here in the orchestrator and assign shares.
"""
log.info("=== TURN 1: CORE AUCTION ===")
agent_states = {}
for agent_id in AGENT_IDS:
agent_states[agent_id] = await engine_agent(client, agent_id)
inputs = []
bids: dict[str, list[tuple[str, int]]] = {} # core_id -> [(agent_id, amount)]
for agent_id in AGENT_IDS:
user_ctx = build_turn1_context(
agent_id,
agent_states[agent_id],
AGENT_IDS,
WORLD_CONFIG,
)
thinking, action_str, t_units, o_units = await call_llm(
llm_client, agent_id, SYSTEM_PROMPT, user_ctx
)
action, speech = parse_action(agent_id, action_str)
log.info(f"[{agent_id}] turn1 action: {action}")
if thinking:
log.debug(f"[{agent_id}] thinking: {thinking[:200]}")
# record bid for auction resolution
if action.get("action") == "bid_core":
core_id = action.get("core_id", "core_0")
amount = action.get("amount", 0)
bids.setdefault(core_id, []).append((agent_id, amount))
inputs.append({
"agent_id": agent_id,
"thinking": thinking,
"action": action,
"speech": speech,
"thinking_units": t_units,
"output_units": o_units,
})
# submit turn to engine
result = await engine_turn(client, inputs)
log.info(f"Turn 1 result: block_winner={result.get('data', {}).get('block_winner')}")
# resolve auction: highest bidder per core wins shares
# tie-break: random (first in list, which is submission order)
core_assignments: dict[str, str] = {}
for core_id, core_bids in bids.items():
if not core_bids:
continue
winner_id, winning_amount = max(core_bids, key=lambda x: x[1])
core_assignments[core_id] = winner_id
log.info(f"Core {core_id}: won by {winner_id} with bid {winning_amount}")
# assign uncontested cores to random agents
num_cores = WORLD_CONFIG["num_cores"]
for i in range(num_cores):
core_id = f"core_{i}"
if core_id not in core_assignments:
# no bids — assign to first agent (they get it free, lucky)
core_assignments[core_id] = AGENT_IDS[i % len(AGENT_IDS)]
log.info(f"Core {core_id}: no bids, assigned to {core_assignments[core_id]}")
# write core shares directly to engine via upsert
# (in a real run you'd POST this back; for now log it so you can seed manually)
log.info(f"Core assignments: {json.dumps(core_assignments, indent=2)}")
return core_assignments
# ─── Main turn loop ────────────────────────────────────────────────────────────
async def run_turn(
client: httpx.AsyncClient,
llm_client: httpx.AsyncClient,
turn: int,
world_state: dict,
last_speech: list,
config: dict,
):
log.info(f"\n{'='*60}\nTURN {turn}\n{'='*60}")
inputs = []
for agent_id in AGENT_IDS:
agent_state = await engine_agent(client, agent_id)
# find contracts this agent is party to
my_contracts = [
c for c in world_state.get("active_contracts", [])
if agent_id in (c.get("proposer"), c.get("counterparty"), c.get("arbitrator"))
]
user_ctx = build_agent_context(
agent_id=agent_id,
agent_state=agent_state,
world_state=world_state,
config=config,
turn=turn,
last_speech=last_speech,
my_contracts=my_contracts,
)
thinking, action_str, t_units, o_units = await call_llm(
llm_client, agent_id, SYSTEM_PROMPT, user_ctx
)
action, speech = parse_action(agent_id, action_str)
log.info(f"[{agent_id}] action={action.get('action')} balance={agent_state.get('balance')}")
if thinking:
log.debug(f"[{agent_id}] think: {thinking[:300]}")
if speech:
log.info(f"[{agent_id}] says: {speech}")
inputs.append({
"agent_id": agent_id,
"thinking": thinking,
"action": action,
"speech": speech,
"thinking_units": t_units,
"output_units": o_units,
})
result = await engine_turn(client, inputs)
data = result.get("data", {})
winner = data.get("block_winner")
fees = data.get("inference_fees_collected", 0)
settled = data.get("contracts_settled", [])
defaulted = data.get("contracts_defaulted", [])
errors = data.get("errors", [])
log.info(f"Block winner: {winner}")
log.info(f"Inference fees: {fees}")
if settled:
log.info(f"Contracts settled: {settled}")
if defaulted:
log.info(f"Contracts defaulted: {defaulted}")
if errors:
for agent_id, err in errors:
log.warning(f"Error [{agent_id}]: {err}")
return data
async def main():
async with httpx.AsyncClient() as client, httpx.AsyncClient() as llm_client:
# init world
log.info("Initializing world...")
await engine_init(client)
# run core auction (turn 1)
await run_core_auction(client, llm_client)
config = WORLD_CONFIG
# main loop
for turn in range(2, TURNS + 1):
world_state = await engine_state(client)
last_speech = await engine_speech(client, turn - 1)
await run_turn(client, llm_client, turn, world_state, last_speech, config)
# print wealth distribution every 5 turns
if turn % 5 == 0:
state = await engine_state(client)
agents = state.get("agents", [])
log.info("\n--- WEALTH SNAPSHOT ---")
for a in sorted(agents, key=lambda x: x.get("balance", 0), reverse=True):
log.info(
f" {a['agent_id']}: "
f"balance={a.get('balance', 0)} "
f"staked={a.get('staked', 0)} "
f"burn={a.get('burn_score', 0):.1f} "
f"study={a.get('study_level', 0)}"
)
log.info(f" token_supply={state.get('token_supply', 0)}")
log.info("-----------------------\n")
await asyncio.sleep(TURN_DELAY)
log.info("Simulation complete.")
if __name__ == "__main__":
asyncio.run(main())