use crate::work_cycle::{self, Action, Backlog, State, Task, WorkerState};
pub trait Reader {
fn tasks(&self) -> Vec<Task>;
fn workers(&self) -> Vec<WorkerState>;
fn treasury_balance(&self) -> u128;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CyclePlan {
pub state_before: State,
pub actions: Vec<Action>,
pub state_after: State,
pub summary: String,
}
impl CyclePlan {
pub fn is_quiescent(&self) -> bool {
self.actions.is_empty()
}
}
pub fn plan_cycle(reader: &impl Reader, max_steps: usize) -> CyclePlan {
let state_before = State {
backlog: Backlog { tasks: reader.tasks() },
workers: reader.workers(),
treasury: reader.treasury_balance(),
};
let mut state = state_before.clone();
let mut actions: Vec<Action> = Vec::new();
let mut steps = 0usize;
for _ in 0..max_steps {
let (next, acts) = work_cycle::step(&state);
state = next;
if acts.is_empty() {
break; }
steps += 1;
actions.extend(acts);
}
let summary = summarize(&state_before, &state, &actions, steps);
CyclePlan { state_before, actions, state_after: state, summary }
}
fn summarize(before: &State, after: &State, actions: &[Action], steps: usize) -> String {
let (mut posts, mut assigns, mut accepts, mut rejects, mut payout_n, mut attests) =
(0u32, 0u32, 0u32, 0u32, 0u32, 0u32);
let mut payout_wei: u128 = 0;
for a in actions {
match a {
Action::PostBounty { .. } => posts += 1,
Action::AssignTask { .. } => assigns += 1,
Action::AcceptResult { .. } => accepts += 1,
Action::RejectResult { .. } => rejects += 1,
Action::Payout { amount, .. } => {
payout_n += 1;
payout_wei = payout_wei.saturating_add(*amount);
}
Action::Attest { .. } => attests += 1,
}
}
format!(
"PLAN (preview only — nothing executed): {steps} step(s), {} action(s) — \
{posts} post, {assigns} assign, {accepts} accept, {rejects} reject, \
{payout_n} payout ({payout_wei} $LH wei), {attests} attest; \
treasury {} → {} (projected)",
actions.len(),
before.treasury,
after.treasury,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::work_cycle::{Criteria, Role, Stage, Submission};
struct MockReader {
tasks: Vec<Task>,
workers: Vec<WorkerState>,
treasury: u128,
}
impl Reader for MockReader {
fn tasks(&self) -> Vec<Task> {
self.tasks.clone()
}
fn workers(&self) -> Vec<WorkerState> {
self.workers.clone()
}
fn treasury_balance(&self) -> u128 {
self.treasury
}
}
fn task(id: u64, role: Role, reward: u128, min_quality: u8, stage: Stage) -> Task {
Task {
id,
role,
reward,
min_reputation: 0,
criteria: Criteria { min_quality },
stage,
}
}
fn worker(id: u64, role: Role, rep: u32) -> WorkerState {
WorkerState { id, role, reputation: rep, available: true }
}
fn submitted(id: u64, role: Role, reward: u128, min_quality: u8, worker_id: u64, quality: u8) -> Task {
task(
id,
role,
reward,
min_quality,
Stage::Submitted { worker_id, submission: Submission { quality, claims_impossible: false } },
)
}
#[test]
fn empty_backlog_plans_no_actions() {
let reader = MockReader { tasks: vec![], workers: vec![worker(1, Role::Coder, 5)], treasury: 1_000 };
let plan = plan_cycle(&reader, 10);
assert!(plan.actions.is_empty());
assert!(plan.is_quiescent());
assert_eq!(plan.state_before, plan.state_after);
assert_eq!(plan.state_after.treasury, 1_000);
assert!(plan.summary.contains("0 action(s)"));
assert!(plan.summary.starts_with("PLAN (preview only"));
}
#[test]
fn staffed_task_plans_post_then_assign_then_idles() {
let reader = MockReader {
tasks: vec![task(1, Role::Coder, 50, 3, Stage::Planned)],
workers: vec![worker(7, Role::Coder, 2)],
treasury: 1_000,
};
let plan = plan_cycle(&reader, 10);
assert_eq!(
plan.actions,
vec![
Action::PostBounty { task_id: 1, reward: 50 },
Action::AssignTask { task_id: 1, worker_id: 7 },
]
);
assert_eq!(plan.state_after.backlog.tasks[0].stage, Stage::Assigned { worker_id: 7 });
assert_eq!(plan.state_after.treasury, 1_000);
assert!(plan.summary.contains("1 post, 1 assign"));
}
#[test]
fn submitted_result_plans_accept_payout_attest() {
let reader = MockReader {
tasks: vec![submitted(1, Role::Coder, 30, 3, 7, 5)],
workers: vec![WorkerState { available: false, ..worker(7, Role::Coder, 2) }],
treasury: 100,
};
let plan = plan_cycle(&reader, 10);
assert_eq!(
plan.actions,
vec![
Action::AcceptResult { task_id: 1, worker_id: 7 },
Action::Payout { task_id: 1, worker_id: 7, amount: 30 },
Action::Attest { subject_id: 7, rating: 5, work_ref: 1 },
]
);
assert_eq!(plan.state_after.backlog.tasks[0].stage, Stage::Accepted { worker_id: 7, rating: 5, paid: 30 });
assert_eq!(plan.state_before.treasury, 100);
assert_eq!(plan.state_after.treasury, 70);
assert!(plan.summary.contains("1 payout (30 $LH wei)"));
assert!(plan.summary.contains("treasury 100 → 70"));
}
#[test]
fn submitted_result_payout_clamps_to_treasury() {
let reader = MockReader {
tasks: vec![submitted(1, Role::Coder, 100, 3, 7, 5)],
workers: vec![WorkerState { available: false, ..worker(7, Role::Coder, 2) }],
treasury: 40,
};
let plan = plan_cycle(&reader, 10);
assert!(plan.actions.contains(&Action::Payout { task_id: 1, worker_id: 7, amount: 40 }));
assert_eq!(plan.state_after.backlog.tasks[0].stage, Stage::Accepted { worker_id: 7, rating: 5, paid: 40 });
assert_eq!(plan.state_after.treasury, 0);
assert!(plan.summary.contains("1 payout (40 $LH wei)"));
}
#[test]
fn submitted_rejection_plans_reject_and_attest_no_payout() {
let reader = MockReader {
tasks: vec![submitted(1, Role::Coder, 30, 4, 7, 2)],
workers: vec![WorkerState { available: false, ..worker(7, Role::Coder, 2) }],
treasury: 100,
};
let plan = plan_cycle(&reader, 10);
assert_eq!(
plan.actions,
vec![
Action::RejectResult { task_id: 1, worker_id: 7 },
Action::Attest { subject_id: 7, rating: 2, work_ref: 1 },
]
);
assert_eq!(plan.state_after.backlog.tasks[0].stage, Stage::Rejected { worker_id: 7, rating: 2 });
assert_eq!(plan.state_after.treasury, 100);
assert!(plan.summary.contains("0 payout"));
assert!(plan.summary.contains("1 reject"));
}
#[test]
fn multi_step_run_reaches_quiescence_on_mixed_board() {
let reader = MockReader {
tasks: vec![
submitted(1, Role::Coder, 30, 3, 7, 5), task(2, Role::Reviewer, 20, 3, Stage::Planned),
task(3, Role::Marketing, 10, 3, Stage::Planned),
],
workers: vec![
WorkerState { available: false, ..worker(7, Role::Coder, 2) },
worker(8, Role::Reviewer, 1),
worker(9, Role::Marketing, 1),
],
treasury: 100,
};
let plan = plan_cycle(&reader, 100);
assert_eq!(plan.state_after.backlog.tasks[0].stage, Stage::Accepted { worker_id: 7, rating: 5, paid: 30 });
assert_eq!(plan.state_after.backlog.tasks[1].stage, Stage::Assigned { worker_id: 8 });
assert_eq!(plan.state_after.backlog.tasks[2].stage, Stage::Assigned { worker_id: 9 });
assert_eq!(plan.state_after.treasury, 70);
assert!(plan.actions.contains(&Action::AcceptResult { task_id: 1, worker_id: 7 }));
assert!(plan.actions.contains(&Action::Payout { task_id: 1, worker_id: 7, amount: 30 }));
assert!(plan.actions.iter().any(|a| matches!(a, Action::PostBounty { task_id: 2, .. })));
assert!(plan.actions.iter().any(|a| matches!(a, Action::PostBounty { task_id: 3, .. })));
assert!(plan.actions.contains(&Action::AssignTask { task_id: 2, worker_id: 8 }));
assert!(plan.actions.contains(&Action::AssignTask { task_id: 3, worker_id: 9 }));
let requies = plan_cycle(
&MockReader {
tasks: plan.state_after.backlog.tasks.clone(),
workers: plan.state_after.workers.clone(),
treasury: plan.state_after.treasury,
},
100,
);
assert!(requies.is_quiescent());
}
#[test]
fn max_steps_bounds_a_busy_board() {
let reader = MockReader {
tasks: vec![
task(1, Role::Coder, 50, 3, Stage::Planned),
task(2, Role::Coder, 40, 3, Stage::Planned),
],
workers: vec![worker(7, Role::Coder, 2)],
treasury: 1_000,
};
let plan = plan_cycle(&reader, 1);
assert_eq!(plan.actions, vec![Action::PostBounty { task_id: 1, reward: 50 }]);
assert_eq!(plan.state_after.backlog.tasks[0].stage, Stage::Posted);
assert!(plan.summary.contains("1 step(s), 1 action(s)"));
}
}