eidos-kernel 0.1.0

Eidos kernel — the pure-logic brain engine (schema, retrieval, ranking, eval). No IO.
Documentation
use std::collections::BTreeSet;

use super::{RetryPolicy, TaskContextItem, TaskDag, TaskNode, TaskOperator, TaskRisk, TaskState};

const MAX_DIRECT_CONTEXT_INSPECTIONS: usize = 24;

/// Draft a conservative non-executing decomposition from a task and its focused context.
///
/// The planner emits a context gather step, one inspection step per receipt-backed context item,
/// then bounded change/check/review/approval steps. It gives agents a stable contract for planning
/// without granting an executor.
pub fn draft_task_dag(task: &str, context: &[TaskContextItem]) -> TaskDag {
    let id = format!("task.{}", slug(task));
    let mut receipts = context
        .iter()
        .filter_map(|item| (!item.source.trim().is_empty()).then_some(item.source.clone()))
        .collect::<Vec<_>>();
    receipts.sort();
    receipts.dedup();

    let has_code = context.iter().any(|item| {
        matches!(
            item.kind.as_str(),
            "function" | "type" | "trait" | "module" | "code"
        )
    });
    let has_docs = context
        .iter()
        .any(|item| matches!(item.kind.as_str(), "doc" | "section" | "skill" | "agent"));

    let mut nodes = vec![TaskNode {
        id: "context".to_string(),
        title: "Gather receipt-backed context".to_string(),
        operator: TaskOperator::ReadContext,
        state: TaskState::Pending,
        depends_on: Vec::new(),
        inputs: vec![task.to_string()],
        outputs: vec!["context_pack".to_string()],
        required_checks: vec!["receipt_coverage".to_string()],
        tools: vec!["focus".to_string()],
        retry: RetryPolicy::default(),
        risk: TaskRisk::Low,
        receipts: receipts.clone(),
    }];

    let mut inspect_ids = Vec::new();
    let mut used_ids = BTreeSet::from(["context".to_string()]);
    for item in context.iter().take(MAX_DIRECT_CONTEXT_INSPECTIONS) {
        let inspect_id = unique_node_id(&format!("inspect-{}", slug(&item.id)), &mut used_ids);
        inspect_ids.push(inspect_id.clone());
        nodes.push(TaskNode {
            id: inspect_id,
            title: format!("Inspect {}", item.title),
            operator: TaskOperator::ReadContext,
            state: TaskState::Pending,
            depends_on: vec!["context".to_string()],
            inputs: vec![item.id.clone()],
            outputs: vec![format!("inspected:{}", item.id)],
            required_checks: vec!["receipt_read".to_string()],
            tools: vec!["read".to_string(), "get_node".to_string()],
            retry: RetryPolicy::default(),
            risk: TaskRisk::Low,
            receipts: if item.source.trim().is_empty() {
                Vec::new()
            } else {
                vec![item.source.clone()]
            },
        });
    }
    if context.len() > MAX_DIRECT_CONTEXT_INSPECTIONS {
        let remaining = &context[MAX_DIRECT_CONTEXT_INSPECTIONS..];
        let inspect_id = unique_node_id("inspect-remaining-context", &mut used_ids);
        inspect_ids.push(inspect_id.clone());
        nodes.push(TaskNode {
            id: inspect_id,
            title: format!("Inspect {} remaining context items", remaining.len()),
            operator: TaskOperator::ReadContext,
            state: TaskState::Pending,
            depends_on: vec!["context".to_string()],
            inputs: remaining.iter().map(|item| item.id.clone()).collect(),
            outputs: vec!["inspected:remaining_context".to_string()],
            required_checks: vec!["receipt_read".to_string()],
            tools: vec!["read".to_string(), "get_node".to_string()],
            retry: RetryPolicy::default(),
            risk: TaskRisk::Low,
            receipts: remaining
                .iter()
                .filter_map(|item| (!item.source.trim().is_empty()).then_some(item.source.clone()))
                .collect(),
        });
    }

    let decompose_deps = if inspect_ids.is_empty() {
        vec!["context".to_string()]
    } else {
        inspect_ids
    };
    nodes.push(TaskNode {
        id: "decompose".to_string(),
        title: "Decompose task into bounded work".to_string(),
        operator: TaskOperator::Decompose,
        state: TaskState::Pending,
        depends_on: decompose_deps,
        inputs: vec!["context_pack".to_string()],
        outputs: vec!["work_items".to_string()],
        required_checks: vec!["dag_is_acyclic".to_string()],
        tools: vec!["brief_task".to_string()],
        retry: RetryPolicy::default(),
        risk: TaskRisk::Low,
        receipts: Vec::new(),
    });

    let change_operator = if has_code {
        TaskOperator::Edit
    } else {
        TaskOperator::ProposeDoc
    };
    nodes.push(TaskNode {
        id: "change".to_string(),
        title: if has_code {
            "Prepare code change proposal".to_string()
        } else if has_docs {
            "Prepare documentation proposal".to_string()
        } else {
            "Prepare knowledge proposal".to_string()
        },
        operator: change_operator,
        state: TaskState::Pending,
        depends_on: vec!["decompose".to_string()],
        inputs: vec!["work_items".to_string()],
        outputs: vec!["change_set".to_string()],
        required_checks: if has_code {
            vec!["compile_or_typecheck".to_string()]
        } else {
            vec!["sigil_validation".to_string()]
        },
        tools: if has_code {
            vec!["edit".to_string()]
        } else {
            vec!["propose".to_string(), "sigil-diff".to_string()]
        },
        retry: RetryPolicy {
            max_attempts: 2,
            backoff_ms: 0,
        },
        risk: if has_code {
            TaskRisk::High
        } else {
            TaskRisk::Medium
        },
        receipts,
    });

    nodes.push(TaskNode {
        id: "verify".to_string(),
        title: "Verify proposed work".to_string(),
        operator: TaskOperator::Check,
        state: TaskState::Pending,
        depends_on: vec!["change".to_string()],
        inputs: vec!["change_set".to_string()],
        outputs: vec!["verification_report".to_string()],
        required_checks: if has_code {
            vec!["tests_pass".to_string(), "lint_passes".to_string()]
        } else {
            vec!["proposal_diff_reviewed".to_string()]
        },
        tools: if has_code {
            vec!["test".to_string(), "lint".to_string()]
        } else {
            vec!["sigil-diff".to_string()]
        },
        retry: RetryPolicy {
            max_attempts: 2,
            backoff_ms: 250,
        },
        risk: TaskRisk::Medium,
        receipts: Vec::new(),
    });

    nodes.push(TaskNode {
        id: "review".to_string(),
        title: "Review receipts and risks".to_string(),
        operator: TaskOperator::Review,
        state: TaskState::Pending,
        depends_on: vec!["verify".to_string()],
        inputs: vec!["verification_report".to_string()],
        outputs: vec!["review_notes".to_string()],
        required_checks: vec!["receipts_match_claims".to_string()],
        tools: vec!["review".to_string()],
        retry: RetryPolicy::default(),
        risk: TaskRisk::Low,
        receipts: Vec::new(),
    });

    nodes.push(TaskNode {
        id: "approval".to_string(),
        title: "Human approval gate".to_string(),
        operator: TaskOperator::HumanApproval,
        state: TaskState::Pending,
        depends_on: vec!["review".to_string()],
        inputs: vec!["review_notes".to_string()],
        outputs: vec!["approved_or_rejected".to_string()],
        required_checks: vec!["human_disposes".to_string()],
        tools: vec!["sigil-accept".to_string()],
        retry: RetryPolicy::default(),
        risk: TaskRisk::High,
        receipts: Vec::new(),
    });

    TaskDag {
        id,
        title: task.trim().to_string(),
        nodes,
    }
}

fn slug(value: &str) -> String {
    let mut out = String::new();
    let mut last_dash = false;
    for ch in value.chars().flat_map(char::to_lowercase) {
        if ch.is_ascii_alphanumeric() {
            out.push(ch);
            last_dash = false;
        } else if !last_dash && !out.is_empty() {
            out.push('-');
            last_dash = true;
        }
    }
    while out.ends_with('-') {
        out.pop();
    }
    if out.is_empty() {
        "untitled".to_string()
    } else {
        out
    }
}

fn unique_node_id(base: &str, used: &mut BTreeSet<String>) -> String {
    let mut candidate = base.to_string();
    let mut suffix = 2usize;
    while !used.insert(candidate.clone()) {
        candidate = format!("{base}-{suffix}");
        suffix += 1;
    }
    candidate
}