1pub const REP_GAIN_ON_ACCEPT: u32 = 1;
24
25#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub struct Criteria {
43 pub min_quality: u8,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Submission {
51 pub quality: u8,
53 pub claims_impossible: bool,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Stage {
64 Planned,
66 Posted,
68 Assigned { worker_id: u64 },
70 Submitted { worker_id: u64, submission: Submission },
72 Accepted { worker_id: u64, rating: u8, paid: u128 },
74 Rejected { worker_id: u64, rating: u8 },
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Task {
83 pub id: u64,
85 pub role: Role,
87 pub reward: u128,
89 pub min_reputation: u32,
91 pub criteria: Criteria,
93 pub stage: Stage,
95}
96
97#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct Backlog {
112 pub tasks: Vec<Task>,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct Assignment {
118 pub task_id: u64,
119 pub worker_id: u64,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum AcceptDecision {
126 Accept { rating: u8 },
128 Reject { rating: u8 },
130}
131
132impl AcceptDecision {
133 pub fn is_accept(self) -> bool {
135 matches!(self, AcceptDecision::Accept { .. })
136 }
137 pub fn rating(self) -> u8 {
139 match self {
140 AcceptDecision::Accept { rating } | AcceptDecision::Reject { rating } => rating,
141 }
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum Action {
150 PostBounty { task_id: u64, reward: u128 },
153 AssignTask { task_id: u64, worker_id: u64 },
156 AcceptResult { task_id: u64, worker_id: u64 },
159 RejectResult { task_id: u64, worker_id: u64 },
162 Payout { task_id: u64, worker_id: u64, amount: u128 },
165 Attest { subject_id: u64, rating: u8, work_ref: u64 },
168}
169
170#[derive(Debug, Clone, Default, PartialEq, Eq)]
172pub struct State {
173 pub backlog: Backlog,
174 pub workers: Vec<WorkerState>,
175 pub treasury: u128,
179}
180
181pub 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 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
212pub 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
228pub fn compute_payout(task: &Task, treasury_balance: u128) -> u128 {
232 task.reward.min(treasury_balance)
233}
234
235impl State {
236 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
254pub fn step(state: &State) -> (State, Vec<Action>) {
261 let mut next = state.clone();
262
263 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()); };
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 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 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
320fn 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
331fn 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 #[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 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), posted(2, Role::Coder, 20, 0, 3),
390 ],
391 };
392 let workers = vec![
393 worker(3, Role::Reviewer, 4), worker(4, Role::Coder, 1), ];
396 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 assert_eq!(assign_next_task(&backlog, &[worker(1, Role::Reviewer, 9)]), None);
415 let busy = WorkerState { available: false, ..worker(1, Role::Coder, 9) };
417 assert_eq!(assign_next_task(&backlog, &[busy]), None);
418 assert_eq!(assign_next_task(&backlog, &[]), None);
420 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 #[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 let halluc = Submission { quality: 5, claims_impossible: true };
444 assert_eq!(evaluate_result(&halluc, &crit), AcceptDecision::Reject { rating: 1 });
445 }
446
447 #[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); assert_eq!(compute_payout(&t, 100), 100); assert_eq!(compute_payout(&t, 40), 40); assert_eq!(compute_payout(&t, 0), 0); }
457
458 #[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 assert!(!state.deliver(1, sub(5)));
479 assert!(!state.deliver(99, sub(5)));
480 }
481
482 #[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 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; let (s, _) = step(&state);
516 state = s; assert!(state.deliver(1, sub(5))); let (s, acts) = step(&state);
519 state = s; 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); 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; let (s, _) = step(&state);
546 state = s; assert!(state.deliver(1, sub(2))); 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); assert!(state.workers[0].available); assert_eq!(state.workers[0].reputation, 2); }
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, };
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); }
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), task(2, Role::Reviewer, 20, 0, 3), ],
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 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 assert!(matches!(state.backlog.tasks[0].stage, Stage::Accepted { paid: 30, .. }));
617 assert!(matches!(state.backlog.tasks[1].stage, Stage::Rejected { .. }));
618 assert_eq!(state.treasury, 70);
620 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 assert!(state.workers.iter().all(|w| w.available));
628 assert_eq!(state.workers[0].reputation, 2); assert_eq!(state.workers[1].reputation, 1); }
631}