Skip to main content

localharness/
work_cycle.rs

1//! Pure work-cycle decision core for an autonomous localharness company — the
2//! logic by which a founded org actually DOES work: allocate a funded task to
3//! the best-fit role-agent, judge the delivered result, pay the worker, and
4//! attest the outcome to reputation. It models ONE
5//! claim → work → judge → pay → attest cycle (the oggoel / agent-coordination
6//! flywheel) as DATA: every decision is a pure function and every side effect is
7//! an [`Action`] descriptor the caller later maps onto a real edge call
8//! (`registry::post_bounty_sponsored` / `claim_bounty_sponsored` /
9//! `accept_result_sponsored` / `attest_sponsored` / a TBA `$LH` transfer).
10//!
11//! Zero I/O, zero chain deps, native + wasm clean — the `keeper.rs` /
12//! `lessons.rs` / `confirm.rs` pattern of a native-testable core hoisted out of
13//! the wiring so the allocation / judgement / payout invariants run under
14//! `cargo test`. Grounded in `design/autonomous-business/STRATEGY.md` (the
15//! role→primitive map), `design/shipped/agent-coordination.md` (the rungs), and
16//! `design/oggoel.md` (the live token-governed-company prior). The on-chain
17//! shapes the [`Action`]s map onto are real (`BountyFacet` status 0 Open / 1
18//! Claimed / 2 Submitted / 3 Paid; `attest(subject, rating 1..=5, workRef)`).
19
20/// Reputation a worker gains when its result is accepted — the proof-of-work
21/// signal that ranks future claims (mirrors a `+` Reviewer attestation; the
22/// Coder role's "reputation climbs from accepted work").
23pub const REP_GAIN_ON_ACCEPT: u32 = 1;
24
25/// A business role an agent fills — each is an identity NFT + TBA with an
26/// on-chain persona (`design/autonomous-business/roles/*.md`). The work cycle
27/// matches a task's required role to a worker that fills it.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Role {
30    Executive,
31    ProductManager,
32    Coder,
33    Reviewer,
34    Accounting,
35    Hr,
36    Marketing,
37}
38
39/// The acceptance bar a Reviewer scores a deliverable against. A submission
40/// rated `>= min_quality` (on the 1..=5 scale) clears it.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct Criteria {
43    /// Minimum 1..=5 quality the Reviewer must score for an accept.
44    pub min_quality: u8,
45}
46
47/// A worker's delivered result, as the Reviewer observes it. The prose artifact
48/// lives off-core; this is the judged summary the decision core needs.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Submission {
51    /// Observed 1..=5 quality / task-fit of the deliverable.
52    pub quality: u8,
53    /// The deliverable claims something impossible on the serverless platform
54    /// (binds a port, runs a daemon, …) — a hallucination the Reviewer scores
55    /// low and auto-rejects regardless of `quality`.
56    pub claims_impossible: bool,
57}
58
59/// A task's position in the claim → work → judge → pay → attest lifecycle (the
60/// `BountyFacet` status line modeled as data). [`Stage::Accepted`] and
61/// [`Stage::Rejected`] are terminal.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Stage {
64    /// In the backlog, reward not yet escrowed.
65    Planned,
66    /// Reward escrowed, open for a worker to claim (BountyFacet Open).
67    Posted,
68    /// Claimed by `worker_id`; work in progress (Claimed).
69    Assigned { worker_id: u64 },
70    /// `worker_id` delivered `submission`; awaiting the Reviewer (Submitted).
71    Submitted { worker_id: u64, submission: Submission },
72    /// Judged accept: `paid` `$LH` settled to the worker, `rating` attested (Paid).
73    Accepted { worker_id: u64, rating: u8, paid: u128 },
74    /// Judged reject: escrow stays reclaimable; the low `rating` is still attested.
75    Rejected { worker_id: u64, rating: u8 },
76}
77
78/// A unit of fundable work. The reward is escrowed when the task is posted and
79/// settled (clamped to the treasury) to the worker's TBA on accept; `role` +
80/// `min_reputation` gate who may be assigned it.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Task {
83    /// Stable id — doubles as the bounty id and the attestation `work_ref`.
84    pub id: u64,
85    /// The role best suited to the work (matched against [`WorkerState::role`]).
86    pub role: Role,
87    /// `$LH` (wei) reward — escrowed at post, paid (clamped) on accept.
88    pub reward: u128,
89    /// Minimum worker reputation eligible to be assigned (0 = anyone).
90    pub min_reputation: u32,
91    /// The Reviewer's acceptance bar.
92    pub criteria: Criteria,
93    /// Lifecycle position.
94    pub stage: Stage,
95}
96
97/// A role-agent's live state for allocation: its id (the tokenId that is both
98/// the `claimBounty` claimant and the `attest` subject), the role it fills, its
99/// reputation (the ranking signal Reviewer attestations build), and whether it
100/// can take a new task this tick.
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct WorkerState {
103    pub id: u64,
104    pub role: Role,
105    pub reputation: u32,
106    pub available: bool,
107}
108
109/// The company's task board.
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct Backlog {
112    pub tasks: Vec<Task>,
113}
114
115/// A decided allocation: give `task_id` to `worker_id`.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct Assignment {
118    pub task_id: u64,
119    pub worker_id: u64,
120}
121
122/// The Reviewer's verdict on a submission — both arms carry the 1..=5 `rating`
123/// to attest (reputation moves on accepts AND rejects, per the Reviewer role).
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum AcceptDecision {
126    /// Meets the bar — settle the reward, attest `rating`.
127    Accept { rating: u8 },
128    /// Below the bar — escrow stays locked, attest the low `rating`.
129    Reject { rating: u8 },
130}
131
132impl AcceptDecision {
133    /// Whether the result was accepted (reward settles).
134    pub fn is_accept(self) -> bool {
135        matches!(self, AcceptDecision::Accept { .. })
136    }
137    /// The 1..=5 rating attested for this verdict (set in both arms).
138    pub fn rating(self) -> u8 {
139        match self {
140            AcceptDecision::Accept { rating } | AcceptDecision::Reject { rating } => rating,
141        }
142    }
143}
144
145/// A side-effect descriptor the [`step`] driver emits — pure DATA the caller
146/// maps onto a real sponsored edge call (named per variant). The core itself
147/// never performs I/O.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum Action {
150    /// Escrow `reward` behind the task spec and open it for claims.
151    /// → `registry::post_bounty_sponsored(task, reward_wei, ttl)`.
152    PostBounty { task_id: u64, reward: u128 },
153    /// Assign the task to the worker (its own tokenId is the claimant).
154    /// → `registry::claim_bounty_sponsored(bounty_id, claimant_token_id)`.
155    AssignTask { task_id: u64, worker_id: u64 },
156    /// Mark the delivered result accepted (releases the bounty escrow).
157    /// → `registry::accept_result_sponsored(bounty_id)`.
158    AcceptResult { task_id: u64, worker_id: u64 },
159    /// Reject the delivered result — escrow stays locked / reclaimable.
160    /// → leave the bounty unaccepted (or `cancelBounty` / `reclaimExpired`).
161    RejectResult { task_id: u64, worker_id: u64 },
162    /// Pay `amount` `$LH` to the worker's TBA (treasury payroll / settle).
163    /// → a TBA `registry::…transfer` / `send_lh` / x402 settle.
164    Payout { task_id: u64, worker_id: u64, amount: u128 },
165    /// Write the 1..=5 reputation attestation keyed to the work.
166    /// → `registry::attest_sponsored(subject_token_id, rating, work_ref)`.
167    Attest { subject_id: u64, rating: u8, work_ref: u64 },
168}
169
170/// The whole company work-cycle state the [`step`] driver advances.
171#[derive(Debug, Clone, Default, PartialEq, Eq)]
172pub struct State {
173    pub backlog: Backlog,
174    pub workers: Vec<WorkerState>,
175    /// Treasury `$LH` (wei) available to pay out — debited at payout, never below
176    /// zero ([`compute_payout`] clamps a payout to it). The on-chain bounty
177    /// escrow debits at post; a caller using escrow-pay reconciles the two.
178    pub treasury: u128,
179}
180
181/// Pick the best `(task, worker)` allocation right now, or `None` if no posted
182/// task has an eligible available worker. Tasks are weighed highest-reward first
183/// (FIFO id tie-break); a worker is eligible when it is `available`, fills the
184/// task's `role`, and meets `min_reputation`; among eligible workers the
185/// highest reputation wins (lowest id tie-break). Only [`Stage::Posted`] tasks
186/// are assignable — a high-reward task with no eligible worker is skipped so a
187/// lower-priority staffable task still gets allocated.
188///
189/// Worker selection is the CANONICAL HR ranker: each posted task becomes a
190/// [`crate::hiring::RoleNeed`] and the worker is the [`crate::hiring::best_candidate`]
191/// over the roster (as [`crate::hiring::Candidate`]s). HR's eligibility +
192/// ordering (exact role, `available`, `reputation >= min_reputation`; highest
193/// reputation, lowest id on a tie) is IDENTICAL to the rule this used to inline,
194/// so behavior is unchanged — they now share ONE implementation and can't drift.
195pub fn assign_next_task(backlog: &Backlog, workers: &[WorkerState]) -> Option<Assignment> {
196    use crate::hiring::{best_candidate, Candidate, RoleNeed};
197
198    let mut posted: Vec<&Task> =
199        backlog.tasks.iter().filter(|t| t.stage == Stage::Posted).collect();
200    posted.sort_by(|a, b| b.reward.cmp(&a.reward).then(a.id.cmp(&b.id)));
201    // ONE roster, ranked per task by the canonical HR ranker.
202    let pool: Vec<Candidate> = workers.iter().copied().map(Candidate::from).collect();
203    for task in posted {
204        let need = RoleNeed { role: task.role, min_reputation: task.min_reputation };
205        if let Some(best) = best_candidate(&need, &pool) {
206            return Some(Assignment { task_id: task.id, worker_id: best.id });
207        }
208    }
209    None
210}
211
212/// Judge a submission against a task's criteria. A deliverable that claims an
213/// impossible (serverless-violating) capability is auto-rejected at rating 1;
214/// otherwise the rating is the observed quality clamped to 1..=5 and the result
215/// is accepted iff it meets `min_quality`.
216pub fn evaluate_result(submission: &Submission, criteria: &Criteria) -> AcceptDecision {
217    if submission.claims_impossible {
218        return AcceptDecision::Reject { rating: 1 };
219    }
220    let rating = submission.quality.clamp(1, 5);
221    if rating >= criteria.min_quality {
222        AcceptDecision::Accept { rating }
223    } else {
224        AcceptDecision::Reject { rating }
225    }
226}
227
228/// The payout for an accepted task: its reward, clamped to the treasury balance
229/// (you can't pay what you don't hold — Accounting flags the shortfall). The
230/// caller debits the treasury by exactly this amount.
231pub fn compute_payout(task: &Task, treasury_balance: u128) -> u128 {
232    task.reward.min(treasury_balance)
233}
234
235impl State {
236    /// Record a worker's delivery: move an [`Stage::Assigned`] task to
237    /// [`Stage::Submitted`] so the next [`step`] judges it. Returns `false` if
238    /// `task_id` isn't currently assigned (a worker can only submit what it
239    /// claimed). This models the off-core "work" half the driver never invents.
240    pub fn deliver(&mut self, task_id: u64, submission: Submission) -> bool {
241        for t in &mut self.backlog.tasks {
242            if t.id == task_id {
243                if let Stage::Assigned { worker_id } = t.stage {
244                    t.stage = Stage::Submitted { worker_id, submission };
245                    return true;
246                }
247                return false;
248            }
249        }
250        false
251    }
252}
253
254/// Advance the cycle by exactly ONE transition and return the new state plus the
255/// [`Action`]s it implies. Priority: judge a delivered result first (finish work
256/// in flight), then assign the best posted task to a worker, then post the next
257/// planned task. The action list is empty when nothing is actionable — every
258/// task is terminal, OR the only work in flight is assigned-but-not-yet-delivered
259/// (the driver is idle, waiting on a worker; call [`State::deliver`] to proceed).
260pub fn step(state: &State) -> (State, Vec<Action>) {
261    let mut next = state.clone();
262
263    // 1. Judge the lowest-id submitted result → pay + attest, or reject + attest.
264    let submitted_idx = next
265        .backlog
266        .tasks
267        .iter()
268        .enumerate()
269        .filter(|(_, t)| matches!(t.stage, Stage::Submitted { .. }))
270        .min_by_key(|(_, t)| t.id)
271        .map(|(i, _)| i);
272    if let Some(i) = submitted_idx {
273        let task = next.backlog.tasks[i].clone();
274        let Stage::Submitted { worker_id: w, submission } = &task.stage else {
275            return (next, Vec::new()); // unreachable by the filter above
276        };
277        let worker_id = *w;
278        let mut actions = Vec::new();
279        match evaluate_result(submission, &task.criteria) {
280            AcceptDecision::Accept { rating } => {
281                let amount = compute_payout(&task, next.treasury);
282                next.treasury -= amount;
283                actions.push(Action::AcceptResult { task_id: task.id, worker_id });
284                actions.push(Action::Payout { task_id: task.id, worker_id, amount });
285                actions.push(Action::Attest { subject_id: worker_id, rating, work_ref: task.id });
286                next.backlog.tasks[i].stage = Stage::Accepted { worker_id, rating, paid: amount };
287                free_worker(&mut next.workers, worker_id, true);
288            }
289            AcceptDecision::Reject { rating } => {
290                actions.push(Action::RejectResult { task_id: task.id, worker_id });
291                actions.push(Action::Attest { subject_id: worker_id, rating, work_ref: task.id });
292                next.backlog.tasks[i].stage = Stage::Rejected { worker_id, rating };
293                free_worker(&mut next.workers, worker_id, false);
294            }
295        }
296        return (next, actions);
297    }
298
299    // 2. Assign the best posted task to its best-fit available worker.
300    if let Some(a) = assign_next_task(&next.backlog, &next.workers) {
301        if let Some(ti) = next.backlog.tasks.iter().position(|t| t.id == a.task_id) {
302            next.backlog.tasks[ti].stage = Stage::Assigned { worker_id: a.worker_id };
303        }
304        if let Some(wi) = next.workers.iter().position(|w| w.id == a.worker_id) {
305            next.workers[wi].available = false;
306        }
307        return (next, vec![Action::AssignTask { task_id: a.task_id, worker_id: a.worker_id }]);
308    }
309
310    // 3. Post the next planned task (escrow its reward, open it for claims).
311    if let Some(i) = min_id_idx(&next.backlog.tasks, |t| t.stage == Stage::Planned) {
312        let (id, reward) = (next.backlog.tasks[i].id, next.backlog.tasks[i].reward);
313        next.backlog.tasks[i].stage = Stage::Posted;
314        return (next, vec![Action::PostBounty { task_id: id, reward }]);
315    }
316
317    (next, Vec::new())
318}
319
320/// Index of the lowest-`id` task matching `pred`, if any (a deterministic
321/// frontier pick independent of `Vec` order).
322fn min_id_idx(tasks: &[Task], pred: impl Fn(&Task) -> bool) -> Option<usize> {
323    tasks
324        .iter()
325        .enumerate()
326        .filter(|(_, t)| pred(t))
327        .min_by_key(|(_, t)| t.id)
328        .map(|(i, _)| i)
329}
330
331/// Free a worker after judgement: mark it available again, and on an accept
332/// bump its reputation by [`REP_GAIN_ON_ACCEPT`] (proven work ranks it higher).
333fn free_worker(workers: &mut [WorkerState], id: u64, accepted: bool) {
334    if let Some(w) = workers.iter_mut().find(|w| w.id == id) {
335        w.available = true;
336        if accepted {
337            w.reputation = w.reputation.saturating_add(REP_GAIN_ON_ACCEPT);
338        }
339    }
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    fn task(id: u64, role: Role, reward: u128, min_rep: u32, min_quality: u8) -> Task {
347        Task {
348            id,
349            role,
350            reward,
351            min_reputation: min_rep,
352            criteria: Criteria { min_quality },
353            stage: Stage::Planned,
354        }
355    }
356
357    fn posted(id: u64, role: Role, reward: u128, min_rep: u32, min_quality: u8) -> Task {
358        Task { stage: Stage::Posted, ..task(id, role, reward, min_rep, min_quality) }
359    }
360
361    fn worker(id: u64, role: Role, rep: u32) -> WorkerState {
362        WorkerState { id, role, reputation: rep, available: true }
363    }
364
365    fn sub(quality: u8) -> Submission {
366        Submission { quality, claims_impossible: false }
367    }
368
369    // --- assign_next_task -------------------------------------------------
370
371    #[test]
372    fn assign_prefers_high_reward_then_high_reputation() {
373        let backlog = Backlog {
374            tasks: vec![posted(1, Role::Coder, 10, 0, 3), posted(2, Role::Coder, 50, 0, 3)],
375        };
376        let workers = vec![worker(7, Role::Coder, 2), worker(8, Role::Coder, 9)];
377        // Highest-reward task (#2) to the highest-reputation eligible worker (#8).
378        assert_eq!(
379            assign_next_task(&backlog, &workers),
380            Some(Assignment { task_id: 2, worker_id: 8 })
381        );
382    }
383
384    #[test]
385    fn assign_respects_role_and_min_reputation_and_skips_unstaffable() {
386        let backlog = Backlog {
387            tasks: vec![
388                posted(1, Role::Reviewer, 100, 5, 3), // top reward, needs a rep>=5 Reviewer
389                posted(2, Role::Coder, 20, 0, 3),
390            ],
391        };
392        let workers = vec![
393            worker(3, Role::Reviewer, 4), // right role, reputation too low for #1
394            worker(4, Role::Coder, 1),    // staffs #2
395        ];
396        // #1 has no eligible worker → fall through to the staffable #2.
397        assert_eq!(
398            assign_next_task(&backlog, &workers),
399            Some(Assignment { task_id: 2, worker_id: 4 })
400        );
401    }
402
403    #[test]
404    fn assign_tie_breaks_lowest_worker_id() {
405        let backlog = Backlog { tasks: vec![posted(1, Role::Coder, 10, 0, 3)] };
406        let workers = vec![worker(9, Role::Coder, 5), worker(4, Role::Coder, 5)];
407        assert_eq!(assign_next_task(&backlog, &workers).unwrap().worker_id, 4);
408    }
409
410    #[test]
411    fn assign_none_when_no_available_matching_worker() {
412        let backlog = Backlog { tasks: vec![posted(1, Role::Coder, 10, 0, 3)] };
413        // Wrong role.
414        assert_eq!(assign_next_task(&backlog, &[worker(1, Role::Reviewer, 9)]), None);
415        // Right role but unavailable.
416        let busy = WorkerState { available: false, ..worker(1, Role::Coder, 9) };
417        assert_eq!(assign_next_task(&backlog, &[busy]), None);
418        // No workers at all.
419        assert_eq!(assign_next_task(&backlog, &[]), None);
420        // A Planned (not yet Posted) task is not assignable.
421        let planned = Backlog { tasks: vec![task(1, Role::Coder, 10, 0, 3)] };
422        assert_eq!(assign_next_task(&planned, &[worker(1, Role::Coder, 9)]), None);
423    }
424
425    // --- evaluate_result --------------------------------------------------
426
427    #[test]
428    fn evaluate_accepts_at_or_above_bar_rejects_below() {
429        let crit = Criteria { min_quality: 3 };
430        assert_eq!(evaluate_result(&sub(3), &crit), AcceptDecision::Accept { rating: 3 });
431        assert_eq!(evaluate_result(&sub(5), &crit), AcceptDecision::Accept { rating: 5 });
432        assert_eq!(evaluate_result(&sub(2), &crit), AcceptDecision::Reject { rating: 2 });
433        assert!(evaluate_result(&sub(3), &crit).is_accept());
434        assert_eq!(evaluate_result(&sub(2), &crit).rating(), 2);
435    }
436
437    #[test]
438    fn evaluate_clamps_rating_and_auto_rejects_hallucination() {
439        let crit = Criteria { min_quality: 3 };
440        assert_eq!(evaluate_result(&sub(9), &crit), AcceptDecision::Accept { rating: 5 });
441        assert_eq!(evaluate_result(&sub(0), &crit), AcceptDecision::Reject { rating: 1 });
442        // A serverless-impossible claim is rejected at 1 even with high quality.
443        let halluc = Submission { quality: 5, claims_impossible: true };
444        assert_eq!(evaluate_result(&halluc, &crit), AcceptDecision::Reject { rating: 1 });
445    }
446
447    // --- compute_payout ---------------------------------------------------
448
449    #[test]
450    fn payout_clamps_to_treasury() {
451        let t = task(1, Role::Coder, 100, 0, 3);
452        assert_eq!(compute_payout(&t, 250), 100); // funded → full reward
453        assert_eq!(compute_payout(&t, 100), 100); // exactly funded
454        assert_eq!(compute_payout(&t, 40), 40); // short → clamped to balance
455        assert_eq!(compute_payout(&t, 0), 0); // broke → nothing
456    }
457
458    // --- deliver ----------------------------------------------------------
459
460    #[test]
461    fn deliver_only_transitions_assigned_tasks() {
462        let mut state = State {
463            backlog: Backlog {
464                tasks: vec![Task {
465                    stage: Stage::Assigned { worker_id: 7 },
466                    ..task(1, Role::Coder, 50, 0, 3)
467                }],
468            },
469            workers: vec![],
470            treasury: 0,
471        };
472        assert!(state.deliver(1, sub(4)));
473        assert_eq!(
474            state.backlog.tasks[0].stage,
475            Stage::Submitted { worker_id: 7, submission: sub(4) }
476        );
477        // Re-delivering (now Submitted) fails; an unknown id fails.
478        assert!(!state.deliver(1, sub(5)));
479        assert!(!state.deliver(99, sub(5)));
480    }
481
482    // --- step driver ------------------------------------------------------
483
484    #[test]
485    fn step_posts_then_assigns_then_idles_on_undelivered_work() {
486        let state = State {
487            backlog: Backlog { tasks: vec![task(1, Role::Coder, 50, 0, 3)] },
488            workers: vec![worker(7, Role::Coder, 2)],
489            treasury: 1_000,
490        };
491        let (s1, a1) = step(&state);
492        assert_eq!(a1, vec![Action::PostBounty { task_id: 1, reward: 50 }]);
493        assert_eq!(s1.backlog.tasks[0].stage, Stage::Posted);
494
495        let (s2, a2) = step(&s1);
496        assert_eq!(a2, vec![Action::AssignTask { task_id: 1, worker_id: 7 }]);
497        assert_eq!(s2.backlog.tasks[0].stage, Stage::Assigned { worker_id: 7 });
498        assert!(!s2.workers[0].available);
499
500        // Assigned but not yet delivered → idle (the driver waits on the worker).
501        let (s3, a3) = step(&s2);
502        assert!(a3.is_empty());
503        assert_eq!(s3.backlog.tasks[0].stage, Stage::Assigned { worker_id: 7 });
504    }
505
506    #[test]
507    fn full_accept_cycle_pays_and_attests() {
508        let mut state = State {
509            backlog: Backlog { tasks: vec![task(1, Role::Coder, 30, 0, 3)] },
510            workers: vec![worker(7, Role::Coder, 2)],
511            treasury: 100,
512        };
513        let (s, _) = step(&state);
514        state = s; // post
515        let (s, _) = step(&state);
516        state = s; // assign
517        assert!(state.deliver(1, sub(5))); // worker delivers a 5★ result
518        let (s, acts) = step(&state);
519        state = s; // judge → pay + attest
520        assert_eq!(
521            acts,
522            vec![
523                Action::AcceptResult { task_id: 1, worker_id: 7 },
524                Action::Payout { task_id: 1, worker_id: 7, amount: 30 },
525                Action::Attest { subject_id: 7, rating: 5, work_ref: 1 },
526            ]
527        );
528        assert_eq!(state.backlog.tasks[0].stage, Stage::Accepted { worker_id: 7, rating: 5, paid: 30 });
529        assert_eq!(state.treasury, 70);
530        assert!(state.workers[0].available);
531        assert_eq!(state.workers[0].reputation, 3); // 2 + REP_GAIN_ON_ACCEPT
532        // Nothing left to do.
533        assert!(step(&state).1.is_empty());
534    }
535
536    #[test]
537    fn reject_cycle_attests_low_and_pays_nothing() {
538        let mut state = State {
539            backlog: Backlog { tasks: vec![task(1, Role::Coder, 30, 0, 4)] },
540            workers: vec![worker(7, Role::Coder, 2)],
541            treasury: 100,
542        };
543        let (s, _) = step(&state);
544        state = s; // post
545        let (s, _) = step(&state);
546        state = s; // assign
547        assert!(state.deliver(1, sub(2))); // weak result, below the min_quality=4 bar
548        let (s, acts) = step(&state);
549        state = s;
550        assert_eq!(
551            acts,
552            vec![
553                Action::RejectResult { task_id: 1, worker_id: 7 },
554                Action::Attest { subject_id: 7, rating: 2, work_ref: 1 },
555            ]
556        );
557        assert_eq!(state.backlog.tasks[0].stage, Stage::Rejected { worker_id: 7, rating: 2 });
558        assert_eq!(state.treasury, 100); // escrow untouched — no payout
559        assert!(state.workers[0].available); // freed to take other work
560        assert_eq!(state.workers[0].reputation, 2); // no gain on a reject
561    }
562
563    #[test]
564    fn accept_cycle_clamps_payout_to_treasury() {
565        let mut state = State {
566            backlog: Backlog { tasks: vec![task(1, Role::Coder, 100, 0, 3)] },
567            workers: vec![worker(7, Role::Coder, 2)],
568            treasury: 40, // less than the 100 reward
569        };
570        let (s, _) = step(&state);
571        state = s;
572        let (s, _) = step(&state);
573        state = s;
574        assert!(state.deliver(1, sub(5)));
575        let (s, acts) = step(&state);
576        state = s;
577        assert!(acts.contains(&Action::Payout { task_id: 1, worker_id: 7, amount: 40 }));
578        assert_eq!(state.backlog.tasks[0].stage, Stage::Accepted { worker_id: 7, rating: 5, paid: 40 });
579        assert_eq!(state.treasury, 0); // drained, not underflowed
580    }
581
582    #[test]
583    fn multi_step_run_drives_two_tasks_to_terminal() {
584        let mut state = State {
585            backlog: Backlog {
586                tasks: vec![
587                    task(1, Role::Coder, 30, 0, 3),    // will be accepted (5★)
588                    task(2, Role::Reviewer, 20, 0, 3), // will be rejected (1★)
589                ],
590            },
591            workers: vec![worker(7, Role::Coder, 1), worker(8, Role::Reviewer, 1)],
592            treasury: 100,
593        };
594        let mut log: Vec<Action> = Vec::new();
595        for _ in 0..50 {
596            // A worker delivers as soon as its task is assigned (the off-core work).
597            let assigned: Vec<u64> = state
598                .backlog
599                .tasks
600                .iter()
601                .filter(|t| matches!(t.stage, Stage::Assigned { .. }))
602                .map(|t| t.id)
603                .collect();
604            for id in assigned {
605                state.deliver(id, sub(if id == 1 { 5 } else { 1 }));
606            }
607            let (s, acts) = step(&state);
608            state = s;
609            if acts.is_empty() {
610                break;
611            }
612            log.extend(acts);
613        }
614
615        // Both tasks reached a terminal stage.
616        assert!(matches!(state.backlog.tasks[0].stage, Stage::Accepted { paid: 30, .. }));
617        assert!(matches!(state.backlog.tasks[1].stage, Stage::Rejected { .. }));
618        // Treasury debited only by the accepted task's clamped payout.
619        assert_eq!(state.treasury, 70);
620        // The whole cycle showed up as data: both posted, one paid, both attested.
621        assert!(log.iter().any(|a| matches!(a, Action::PostBounty { task_id: 1, .. })));
622        assert!(log.iter().any(|a| matches!(a, Action::PostBounty { task_id: 2, .. })));
623        assert!(log.contains(&Action::Payout { task_id: 1, worker_id: 7, amount: 30 }));
624        assert!(log.iter().any(|a| matches!(a, Action::Attest { subject_id: 7, rating: 5, work_ref: 1 })));
625        assert!(log.iter().any(|a| matches!(a, Action::Attest { subject_id: 8, work_ref: 2, .. })));
626        // Both workers freed; the accepted worker's reputation climbed, the other's didn't.
627        assert!(state.workers.iter().all(|w| w.available));
628        assert_eq!(state.workers[0].reputation, 2); // 1 + gain
629        assert_eq!(state.workers[1].reputation, 1); // unchanged on reject
630    }
631}