aivcs-core 0.3.1

Core library for AIVCS domain logic and orchestration
Documentation
use std::collections::{BTreeMap, BTreeSet};

use aivcs_core::{
    compute_progress, decompose_goal_to_dag, evaluate_replan, schedule_next_ready_tasks, EpicPlan,
    GoalPlan, PlanTask, PlanTaskStatus, ReplanPolicy, SchedulerConstraints, TaskPlan,
};
use chrono::{Duration, Utc};

fn mk_task(id: &str, deps: &[&str]) -> TaskPlan {
    TaskPlan {
        id: id.to_string(),
        title: format!("task-{id}"),
        depends_on: deps.iter().map(|s| s.to_string()).collect(),
        estimate_hours: 4,
    }
}

#[test]
fn complex_objective_decomposes_into_executable_dag() {
    let goal = GoalPlan {
        id: "goal-1".to_string(),
        objective: "deliver private coding assistant".to_string(),
        epics: vec![
            EpicPlan {
                id: "epic-a".to_string(),
                title: "platform".to_string(),
                tasks: vec![mk_task("t1", &[]), mk_task("t2", &["t1"])],
            },
            EpicPlan {
                id: "epic-b".to_string(),
                title: "runtime".to_string(),
                tasks: vec![mk_task("t3", &["t2"]), mk_task("t4", &["t2"])],
            },
        ],
    };

    let dag = decompose_goal_to_dag(&goal).expect("decompose");
    dag.validate().expect("valid dag");

    assert_eq!(dag.tasks.len(), 4);
    assert!(dag
        .tasks
        .get("t2")
        .unwrap()
        .depends_on
        .contains(&"t1".to_string()));
}

#[test]
fn scheduler_respects_dependencies_and_constraints() {
    let now = Utc::now();
    let mut tasks = BTreeMap::new();
    tasks.insert("t1".to_string(), PlanTask::pending("t1", vec![], now));
    tasks.insert(
        "t2".to_string(),
        PlanTask::pending("t2", vec!["t1".to_string()], now),
    );
    tasks.insert(
        "t3".to_string(),
        PlanTask::pending("t3", vec!["t1".to_string()], now),
    );

    let dag = aivcs_core::ExecutionDag {
        goal_id: "g".to_string(),
        objective: "obj".to_string(),
        tasks,
    };

    let c = SchedulerConstraints {
        max_parallel: 1,
        blocked_tasks: BTreeSet::new(),
    };
    let ready = schedule_next_ready_tasks(&dag, &c).expect("schedule");
    assert_eq!(ready, vec!["t1".to_string()]);

    let mut dag2 = dag.clone();
    dag2.tasks.get_mut("t1").unwrap().status = PlanTaskStatus::Done;
    let c2 = SchedulerConstraints {
        max_parallel: 2,
        blocked_tasks: BTreeSet::from(["t3".to_string()]),
    };
    let ready2 = schedule_next_ready_tasks(&dag2, &c2).expect("schedule2");
    assert_eq!(ready2, vec!["t2".to_string()]);
}

#[test]
fn progress_reporting_matches_execution_reality() {
    let now = Utc::now();
    let mut tasks = BTreeMap::new();
    let mut t1 = PlanTask::pending("t1", vec![], now);
    t1.status = PlanTaskStatus::Done;
    t1.confidence = 0.9;
    let mut t2 = PlanTask::pending("t2", vec![], now);
    t2.status = PlanTaskStatus::InProgress;
    t2.confidence = 0.7;
    let mut t3 = PlanTask::pending("t3", vec![], now);
    t3.status = PlanTaskStatus::Blocked {
        reason: "waiting on API key".to_string(),
    };
    t3.confidence = 0.5;

    tasks.insert("t1".to_string(), t1);
    tasks.insert("t2".to_string(), t2);
    tasks.insert("t3".to_string(), t3);

    let dag = aivcs_core::ExecutionDag {
        goal_id: "g".to_string(),
        objective: "obj".to_string(),
        tasks,
    };

    let report = compute_progress(&dag);
    assert_eq!(report.total_tasks, 3);
    assert_eq!(report.done_tasks, 1);
    assert_eq!(report.in_progress_tasks, 1);
    assert_eq!(report.blocked_tasks, 1);
    assert_eq!(report.completion_ratio, 1.0 / 3.0);
    assert_eq!(report.blockers, vec!["waiting on API key".to_string()]);
}

#[test]
fn replans_trigger_automatically_on_drift_failure_and_blockers() {
    let now = Utc::now();
    let mut tasks = BTreeMap::new();

    let mut t1 = PlanTask::pending("t1", vec![], now - Duration::hours(30));
    t1.status = PlanTaskStatus::Failed {
        reason: "compile failed".to_string(),
    };
    t1.confidence = 0.3;

    let mut t2 = PlanTask::pending("t2", vec![], now - Duration::hours(30));
    t2.status = PlanTaskStatus::Blocked {
        reason: "dependency outage".to_string(),
    };
    t2.confidence = 0.4;

    let t3 = PlanTask::pending("t3", vec![], now - Duration::hours(30));

    tasks.insert("t1".to_string(), t1);
    tasks.insert("t2".to_string(), t2);
    tasks.insert("t3".to_string(), t3);

    let dag = aivcs_core::ExecutionDag {
        goal_id: "g".to_string(),
        objective: "obj".to_string(),
        tasks,
    };

    let policy = ReplanPolicy {
        min_confidence: 0.6,
        max_blocked_ratio: 0.2,
        trigger_on_failure: true,
        max_stale_hours: 12,
    };

    let decision = evaluate_replan(&dag, &policy, now);
    assert!(decision.should_replan);
    assert!(!decision.reasons.is_empty());
}