Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
build/
/build/
*.o
planner
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@

PlannerAI is a C++ scheduling engine that turns tasks, deadlines, dependencies, available time blocks, and an hourly energy curve into a realistic work plan. The optimizer favors urgent/high-value work, avoids prerequisite violations, and can split long tasks across multiple blocks.

Current state: CSV input, constraint checks, scoring, scheduling, JSON output, a demo dataset, and core tests are working. Calendar import and interactive replanning are still in progress.
Current state: CSV input, dependency and non-overlap validation, hard deadline enforcement, energy-aware scoring, explainable unscheduled work, JSON output, a demo dataset, and core tests are working. Calendar import and interactive replanning are still in progress.

```bash
cmake -S . -B build && cmake --build build
./build/planner data/tasks.csv data/availability.csv
ctest --test-dir build --output-on-failure
```

2 changes: 1 addition & 1 deletion include/planner.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ struct ScheduledBlock {
struct Plan {
std::vector<ScheduledBlock> blocks;
std::unordered_map<std::string, int> unscheduled_minutes;
std::unordered_map<std::string, std::string> unscheduled_reasons;
double objective{};
};

Expand All @@ -56,4 +57,3 @@ std::string plan_to_json(const Plan& plan);
std::string minute_to_clock(int minute);

} // namespace plannerai

33 changes: 28 additions & 5 deletions src/planner.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,23 @@ void Scheduler::validate(const std::vector<Task>& tasks, const std::vector<Avail
if (task.duration_minutes <= 0 || task.minimum_chunk <= 0) throw std::invalid_argument("durations must be positive");
if (task.priority < 1 || task.priority > 5) throw std::invalid_argument("priority must be 1..5");
if (task.cognitive_load < 0 || task.cognitive_load > 1) throw std::invalid_argument("cognitive load must be 0..1");
if (task.deadline_minute <= 0) throw std::invalid_argument("deadline must be positive");
}
for (const auto& task : tasks) {
for (const auto& dependency : task.dependencies) {
if (!ids.contains(dependency)) throw std::invalid_argument("unknown dependency: " + dependency);
}
}
if (dependency_cycle(tasks)) throw std::invalid_argument("task dependency cycle detected");
for (const auto& slot : availability) {
std::vector<Availability> ordered = availability;
std::sort(ordered.begin(), ordered.end(), [](const auto& left, const auto& right) {
return left.start_minute < right.start_minute;
});
for (std::size_t index = 0; index < ordered.size(); ++index) {
const auto& slot = ordered[index];
if (slot.end_minute <= slot.start_minute) throw std::invalid_argument("availability must have positive length");
if (slot.energy < 0 || slot.energy > 1) throw std::invalid_argument("energy must be 0..1");
if (index && slot.start_minute < ordered[index - 1].end_minute) throw std::invalid_argument("availability blocks must not overlap");
}
}

Expand Down Expand Up @@ -107,11 +114,12 @@ Plan Scheduler::build(std::vector<Task> tasks, std::vector<Availability> availab
double best_score = -1e9;
for (const auto& task : tasks) {
if (remaining[task.id] <= 0) continue;
if (cursor >= task.deadline_minute) continue;
const bool ready = std::all_of(task.dependencies.begin(), task.dependencies.end(), [&](const auto& dep) {
return completed.contains(dep);
});
if (!ready) continue;
const int free = slot.end_minute - cursor;
const int free = std::min(slot.end_minute, task.deadline_minute) - cursor;
if (free < std::min(task.minimum_chunk, remaining[task.id])) continue;
const double candidate = score(task, slot, cursor, remaining[task.id]);
if (candidate > best_score || (candidate == best_score && task.deadline_minute < best->deadline_minute)) {
Expand All @@ -120,15 +128,26 @@ Plan Scheduler::build(std::vector<Task> tasks, std::vector<Availability> availab
}
}
if (!best) break;
const int chunk = std::min(remaining[best->id], slot.end_minute - cursor);
const int chunk = std::min({remaining[best->id], slot.end_minute - cursor, best->deadline_minute - cursor});
plan.blocks.push_back({best->id, best->title, cursor, cursor + chunk, best_score});
plan.objective += best_score * chunk / 60.0;
remaining[best->id] -= chunk;
cursor += chunk;
if (remaining[best->id] == 0) completed.insert(best->id);
}
}
for (const auto& [id, minutes] : remaining) if (minutes > 0) plan.unscheduled_minutes[id] = minutes;
const int horizon = availability.empty() ? 0 : std::max_element(availability.begin(), availability.end(), [](const auto& left, const auto& right) {
return left.end_minute < right.end_minute;
})->end_minute;
for (const auto& [id, minutes] : remaining) if (minutes > 0) {
plan.unscheduled_minutes[id] = minutes;
const auto& task = by_id.at(id);
const bool blocked = std::any_of(task.dependencies.begin(), task.dependencies.end(), [&](const auto& dependency) {
return remaining.at(dependency) > 0;
});
plan.unscheduled_reasons[id] = blocked ? "dependency_incomplete"
: task.deadline_minute <= horizon ? "deadline_capacity" : "insufficient_capacity";
}
return plan;
}

Expand Down Expand Up @@ -188,9 +207,13 @@ std::string plan_to_json(const Plan& plan) {
for (const auto& [id, minutes] : plan.unscheduled_minutes) {
out << (index++ ? ", " : "") << "\"" << escape_json(id) << "\": " << minutes;
}
out << "},\n \"unscheduled_reasons\": {";
index = 0;
for (const auto& [id, reason] : plan.unscheduled_reasons) {
out << (index++ ? ", " : "") << "\"" << escape_json(id) << "\": \"" << escape_json(reason) << "\"";
}
out << "}\n}\n";
return out.str();
}

} // namespace plannerai

20 changes: 18 additions & 2 deletions tests/planner_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,26 @@ void cycles_are_rejected() {
assert(threw);
}

void hard_deadlines_are_never_crossed() {
std::vector<Task> tasks{{"due", "Due soon", 90, 5, 540, 30, .8, {}}};
Plan plan = Scheduler().build(tasks, {{510, 600, .8}});
assert(plan.blocks.size() == 1);
assert(plan.blocks[0].end_minute == 540);
assert(plan.unscheduled_minutes.at("due") == 60);
assert(plan.unscheduled_reasons.at("due") == "deadline_capacity");
}

void overlapping_availability_is_rejected() {
bool threw = false;
try { Scheduler().build({}, {{480, 540, .5}, {530, 600, .7}}); } catch (const std::invalid_argument&) { threw = true; }
assert(threw);
}

int main() {
dependency_order_is_preserved();
impossible_capacity_is_reported();
cycles_are_rejected();
std::cout << "3 planner tests passed\n";
hard_deadlines_are_never_crossed();
overlapping_availability_is_rejected();
std::cout << "5 planner tests passed\n";
}