dpm-model 0.1.0

Domain model for DPM execution planning
Documentation
use crate::*;
use chrono::{DateTime, TimeDelta, TimeZone, Utc};
use std::collections::BTreeSet;

fn fixture() -> Plan {
    serde_json::from_str(include_str!(
        "../../../../tests/support/execution-plan.json"
    ))
    .expect("fixture")
}

fn t(hours: i64) -> DateTime<Utc> {
    Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0)
        .single()
        .expect("fixed time")
        + TimeDelta::hours(hours)
}

fn id(plan: &Plan, key: &str) -> WorkItemId {
    plan.find_work_by_key(key).expect("work").id
}

fn verify(plan: &mut Plan, key: &str, at: Option<DateTime<Utc>>) {
    let work = plan.find_work_by_key_mut(key).expect("work");
    work.execution.status = WorkStatus::Verified;
    work.execution.owner = Some(ActorId::agent("worker"));
    work.execution.events.verified_at = at;
}

fn gate(plan: &mut Plan, key: &str, blocks: WorkItemId, resolved: Option<DateTime<Utc>>) {
    let project = plan.work_items[&blocks].project;
    let decision = Decision {
        id: DecisionId::new(),
        key: Key::new(key),
        project,
        question: "Proceed?".into(),
        status: DecisionStatus::Decided,
        outcome: Some("Yes".into()),
        resolved_at: resolved,
        rationale: None,
        related_work: BTreeSet::new(),
        artifact_ids: BTreeSet::new(),
        blocks: BTreeSet::from([blocks]),
        supersedes: None,
        options: Vec::new(),
    };
    plan.decisions.insert(decision.id, decision);
}

#[test]
fn a_decision_resolved_after_every_prerequisite_sets_the_milestone_time() {
    let mut plan = fixture();
    let milestone = id(&plan, "TEST-M1");
    verify(&mut plan, "TEST-F", Some(t(1)));
    gate(&mut plan, "LATE-GATE", milestone, Some(t(5)));
    plan.validate().expect("valid");
    let timeline = Timeline::at(&plan, t(6));
    assert_eq!(
        timeline.completed_at(milestone),
        Some(EventTime::Recorded(t(5)))
    );
    assert_eq!(
        timeline.event(&plan, milestone, Endpoint::Start),
        timeline.event(&plan, milestone, Endpoint::Finish),
        "a zero-duration milestone starts and finishes at its reach event"
    );
    let open = plan
        .decisions
        .values_mut()
        .find(|d| d.key.0 == "LATE-GATE")
        .expect("gate");
    open.status = DecisionStatus::Open;
    open.outcome = None;
    open.resolved_at = None;
    assert!(!completion(&plan, t(6)).contains(&milestone));
}

#[test]
fn milestone_time_follows_the_latest_prerequisite_including_lag() {
    let mut plan = fixture();
    let milestone = id(&plan, "TEST-M1");
    verify(&mut plan, "TEST-F", Some(t(1)));
    plan.dependencies
        .iter_mut()
        .find(|d| d.successor == milestone)
        .expect("edge")
        .lag_hours = 24.0;
    assert!(!completion(&plan, t(24)).contains(&milestone));
    assert_eq!(
        Timeline::at(&plan, t(25)).completed_at(milestone),
        Some(EventTime::Recorded(t(25)))
    );
}

#[test]
fn legacy_verification_without_a_time_releases_zero_lag_but_not_positive_lag() {
    let mut plan = fixture();
    let milestone = id(&plan, "TEST-M1");
    verify(&mut plan, "TEST-F", None);
    let timeline = Timeline::at(&plan, t(0));
    assert_eq!(
        timeline.completed_at(milestone),
        Some(EventTime::Unrecorded)
    );
    let edge = plan
        .dependencies
        .iter_mut()
        .find(|d| d.successor == milestone)
        .expect("edge");
    edge.lag_hours = 1.0;
    let edge = edge.clone();
    let timeline = Timeline::at(&plan, t(10_000));
    assert_eq!(timeline.edge(&plan, &edge), Release::UnrecordedEventTime);
    assert_eq!(timeline.completed_at(milestone), None);
}

#[test]
fn start_events_come_from_the_start_command_or_a_started_lifecycle() {
    let mut plan = fixture();
    let a = id(&plan, "TEST-A");
    let timeline = Timeline::at(&plan, t(0));
    assert_eq!(timeline.event(&plan, a, Endpoint::Start), None);
    let work = plan.find_work_by_key_mut("TEST-A").expect("work");
    work.execution.status = WorkStatus::Claimed;
    work.execution.owner = Some(ActorId::agent("worker"));
    assert_eq!(
        Timeline::at(&plan, t(0)).event(&plan, a, Endpoint::Start),
        None,
        "a claim reserves work; it is not a start"
    );
    let work = plan.find_work_by_key_mut("TEST-A").expect("work");
    work.execution.status = WorkStatus::InProgress;
    assert_eq!(
        Timeline::at(&plan, t(0)).event(&plan, a, Endpoint::Start),
        Some(EventTime::Unrecorded)
    );
    plan.find_work_by_key_mut("TEST-A")
        .expect("work")
        .execution
        .events
        .started_at = Some(t(3));
    let timeline = Timeline::at(&plan, t(3));
    assert_eq!(
        timeline.event(&plan, a, Endpoint::Start),
        Some(EventTime::Recorded(t(3)))
    );
    assert_eq!(timeline.event(&plan, a, Endpoint::Finish), None);
}

#[test]
fn waived_edges_do_not_gate_milestones_and_packages_roll_up_child_times() {
    let mut plan = fixture();
    let milestone = id(&plan, "TEST-M1");
    let edge = plan
        .dependencies
        .iter_mut()
        .find(|d| d.successor == milestone)
        .expect("edge");
    edge.policy = DependencyPolicy::Soft;
    edge.waiver = Some(DependencyWaiver {
        actor: ActorId::human("lead"),
        at: t(0),
        reason: "scope moved".into(),
    });
    assert!(
        !completion(&plan, t(1)).contains(&milestone),
        "a milestone with no enforced prerequisite stays unreached"
    );
}

#[test]
fn event_validation_rejects_backdated_or_mismatched_times() {
    let mut plan = fixture();
    let work = plan.find_work_by_key_mut("TEST-A").expect("work");
    work.execution.status = WorkStatus::Verified;
    work.execution.owner = Some(ActorId::agent("worker"));
    work.execution.events.started_at = Some(t(5));
    work.execution.events.submitted_at = Some(t(6));
    work.execution.events.verified_at = Some(t(7));
    plan.validate().expect("ordered events");
    plan.find_work_by_key_mut("TEST-A")
        .expect("work")
        .execution
        .events
        .verified_at = Some(t(4));
    assert!(plan.validate().is_err());
    let mut plan = fixture();
    plan.find_work_by_key_mut("TEST-A")
        .expect("work")
        .execution
        .events
        .started_at = Some(t(1));
    assert!(plan.validate().is_err(), "a planned task has not started");
    let mut plan = fixture();
    plan.find_work_by_key_mut("TEST-M1")
        .expect("work")
        .execution
        .events
        .started_at = Some(t(1));
    assert!(plan.validate().is_err(), "milestones have derived events");
    let mut plan = fixture();
    plan.decisions
        .values_mut()
        .next()
        .expect("gate")
        .resolved_at = Some(t(1));
    assert!(plan.validate().is_err(), "an open gate has no resolution");
}